diff --git a/client/src/pages/ImportSettings.tsx b/client/src/pages/ImportSettings.tsx
index 519e3ad..1c1ee62 100644
--- a/client/src/pages/ImportSettings.tsx
+++ b/client/src/pages/ImportSettings.tsx
@@ -46,6 +46,10 @@ export default function ImportSettings() {
const [exportFolderType, setExportFolderType] = useState<"local" | "teams" | "sharepoint">("local");
// Compat. ancienne valeur
const [bapExportMode, setBapExportMode] = useState<"browser" | "folder" | "both">("browser");
+ // Azure AD credentials for SharePoint
+ const [azureTenantId, setAzureTenantId] = useState("");
+ const [azureClientId, setAzureClientId] = useState("");
+ const [azureClientSecret, setAzureClientSecret] = useState("");
// Initialize form with settings from database
useEffect(() => {
@@ -66,10 +70,19 @@ export default function ImportSettings() {
setBapExportBrowser(savedMode === "browser" || savedMode === "both");
setBapExportFolder(savedMode === "folder" || savedMode === "both");
setBapExportMode(savedMode as "browser" | "folder" | "both");
- // Lire le type de dossier depuis exportFolder (préfixe teams:// ou sharepoint://)
- if ((settings.exportFolder || "").startsWith("teams://")) setExportFolderType("teams");
- else if ((settings.exportFolder || "").startsWith("sharepoint://")) setExportFolderType("sharepoint");
- else setExportFolderType("local");
+ // Lire le type de dossier depuis le champ dédié exportFolderType
+ if ((settings as any).exportFolderType) {
+ setExportFolderType((settings as any).exportFolderType as "local" | "teams" | "sharepoint");
+ } else {
+ // Compat. ancienne logique préfixe
+ if ((settings.exportFolder || "").startsWith("teams://")) setExportFolderType("teams");
+ else if ((settings.exportFolder || "").startsWith("sharepoint://")) setExportFolderType("sharepoint");
+ else setExportFolderType("local");
+ }
+ // Azure AD
+ setAzureTenantId((settings as any).azureTenantId || "");
+ setAzureClientId((settings as any).azureClientId || "");
+ setAzureClientSecret((settings as any).azureClientSecret || "");
}
}, [settings]);
@@ -94,7 +107,11 @@ export default function ImportSettings() {
emailImportPort: emailImportPort,
emailImportFrequency: emailImportFrequency,
exportFolder: exportFolder || null,
- bapExportMode: computedExportMode() as "browser" | "folder",
+ exportFolderType: exportFolderType,
+ bapExportMode: computedExportMode() as "browser" | "folder" | "both",
+ azureTenantId: azureTenantId || null,
+ azureClientId: azureClientId || null,
+ azureClientSecret: azureClientSecret || null,
});
toast.success("Paramètres enregistrés avec succès");
@@ -620,6 +637,52 @@ export default function ImportSettings() {
{exportFolderType === "sharepoint" && "URL complète du dossier SharePoint de destination"}
+
+ {/* Champs Azure AD — visibles uniquement pour SharePoint */}
+ {exportFolderType === "sharepoint" && (
+
+
+
+
Configuration Microsoft Azure AD
+
+
+
+
+ setAzureTenantId(e.target.value)}
+ className="h-10 font-mono text-sm"
+ />
+
+
+
+ setAzureClientId(e.target.value)}
+ className="h-10 font-mono text-sm"
+ />
+
+
+
+
setAzureClientSecret(e.target.value)}
+ className="h-10 font-mono text-sm"
+ />
+
Stocké de façon sécurisée. Permissions requises : Files.ReadWrite.All et Sites.ReadWrite.All
+
+
+
+ )}
)}
diff --git a/drizzle/0024_real_kylun.sql b/drizzle/0024_real_kylun.sql
new file mode 100644
index 0000000..9b97213
--- /dev/null
+++ b/drizzle/0024_real_kylun.sql
@@ -0,0 +1,3 @@
+ALTER TABLE `importSettings` ADD `azureTenantId` varchar(100);--> statement-breakpoint
+ALTER TABLE `importSettings` ADD `azureClientId` varchar(100);--> statement-breakpoint
+ALTER TABLE `importSettings` ADD `azureClientSecret` text;
\ No newline at end of file
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 389bb70..b0d0eb3 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -185,7 +185,12 @@ export const importSettings = mysqlTable("importSettings", {
// Export folder settings
exportFolder: text("exportFolder"), // Path to folder for exporting invoices
+ exportFolderType: mysqlEnum("exportFolderType", ["local", "teams", "sharepoint"]).default("local").notNull(), // Type de destination d'export
bapExportMode: mysqlEnum("bapExportMode", ["browser", "folder", "both"]).default("browser").notNull(), // BAP export mode: open in browser or save to folder
+ // Azure AD / Microsoft Graph credentials for SharePoint export
+ azureTenantId: varchar("azureTenantId", { length: 100 }), // Azure AD Tenant ID
+ azureClientId: varchar("azureClientId", { length: 100 }), // Azure AD Application (client) ID
+ azureClientSecret: text("azureClientSecret"), // Azure AD Client Secret (encrypted)
// AI Engine settings
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider for invoice extraction
diff --git a/server/routers.ts b/server/routers.ts
index 5ec8322..be0233c 100644
--- a/server/routers.ts
+++ b/server/routers.ts
@@ -470,6 +470,7 @@ export const appRouter = router({
const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser';
const exportFolder = importSettings?.exportFolder || null;
+ const exportFolderType = (importSettings as any)?.exportFolderType || 'local';
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
let pdfUrl: string | null = null;
@@ -550,12 +551,37 @@ export const appRouter = router({
const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`;
- if (bapExportMode === 'folder' && exportFolder) {
- // Mode dossier : enregistrer sur le disque
- await fs.mkdir(exportFolder, { recursive: true });
- exportPath = path.join(exportFolder, bapFilename);
- await fs.writeFile(exportPath, signedPdfBytes);
- } else {
+ if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
+ if (exportFolderType === 'sharepoint') {
+ // Mode SharePoint : upload via Microsoft Graph
+ const { uploadToSharePoint } = await import('./sharepoint');
+ const spResult = await uploadToSharePoint(
+ {
+ tenantId: (importSettings as any)?.azureTenantId || '',
+ clientId: (importSettings as any)?.azureClientId || '',
+ clientSecret: (importSettings as any)?.azureClientSecret || '',
+ sharepointUrl: exportFolder,
+ },
+ Buffer.from(signedPdfBytes),
+ bapFilename
+ );
+ if (spResult.success) {
+ exportPath = spResult.webUrl || exportFolder;
+ } else {
+ console.warn('[BAP] Erreur upload SharePoint:', spResult.error);
+ // Fallback : stocker localement
+ const bapKey = generateStorageKey(ctx.user.id, bapFilename);
+ const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
+ pdfUrl = url;
+ }
+ } else {
+ // Mode dossier local : enregistrer sur le disque
+ await fs.mkdir(exportFolder, { recursive: true });
+ exportPath = path.join(exportFolder, bapFilename);
+ await fs.writeFile(exportPath, signedPdfBytes);
+ }
+ }
+ if (bapExportMode === 'browser' || bapExportMode === 'both' || !exportFolder) {
// Mode navigateur : stocker localement et retourner l'URL relative
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
@@ -625,6 +651,7 @@ export const appRouter = router({
const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser';
const exportFolder = importSettings?.exportFolder || null;
+ const exportFolderType = (importSettings as any)?.exportFolderType || 'local';
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
@@ -702,11 +729,34 @@ export const appRouter = router({
const signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`;
- if (bapExportMode === 'folder' && exportFolder) {
- await fs.mkdir(exportFolder, { recursive: true });
- exportPath = path.join(exportFolder, bapFilename);
- await fs.writeFile(exportPath, signedPdfBytes);
- } else {
+ if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
+ if (exportFolderType === 'sharepoint') {
+ const { uploadToSharePoint } = await import('./sharepoint');
+ const spResult = await uploadToSharePoint(
+ {
+ tenantId: (importSettings as any)?.azureTenantId || '',
+ clientId: (importSettings as any)?.azureClientId || '',
+ clientSecret: (importSettings as any)?.azureClientSecret || '',
+ sharepointUrl: exportFolder,
+ },
+ Buffer.from(signedPdfBytes),
+ bapFilename
+ );
+ if (spResult.success) {
+ exportPath = spResult.webUrl || exportFolder;
+ } else {
+ console.warn('[BAP Bulk] Erreur upload SharePoint:', spResult.error);
+ const bapKey = generateStorageKey(ctx.user.id, bapFilename);
+ const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
+ pdfUrl = url;
+ }
+ } else {
+ await fs.mkdir(exportFolder, { recursive: true });
+ exportPath = path.join(exportFolder, bapFilename);
+ await fs.writeFile(exportPath, signedPdfBytes);
+ }
+ }
+ if (bapExportMode === 'browser' || bapExportMode === 'both' || !exportFolder) {
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
pdfUrl = url;
@@ -1410,7 +1460,11 @@ export const appRouter = router({
emailImportPort: 993,
emailImportFrequency: 30,
exportFolder: null,
+ exportFolderType: "local" as const,
bapExportMode: "browser" as const,
+ azureTenantId: null,
+ azureClientId: null,
+ azureClientSecret: null,
};
}
@@ -1430,7 +1484,11 @@ export const appRouter = router({
emailImportPort: z.number().min(1).max(65535).optional(),
emailImportFrequency: z.number().min(1).optional(),
exportFolder: z.string().nullable().optional(),
+ exportFolderType: z.enum(["local", "teams", "sharepoint"]).optional(),
bapExportMode: z.enum(["browser", "folder", "both"]).optional(),
+ azureTenantId: z.string().nullable().optional(),
+ azureClientId: z.string().nullable().optional(),
+ azureClientSecret: z.string().nullable().optional(),
}))
.mutation(async ({ input, ctx }) => {
const settings = await upsertImportSettings({
diff --git a/server/sharepoint.ts b/server/sharepoint.ts
new file mode 100644
index 0000000..75fa7e6
--- /dev/null
+++ b/server/sharepoint.ts
@@ -0,0 +1,208 @@
+/**
+ * Microsoft Graph helper for uploading files to SharePoint
+ * Uses client credentials flow (app-only authentication)
+ */
+
+interface SharePointConfig {
+ tenantId: string;
+ clientId: string;
+ clientSecret: string;
+ sharepointUrl: string; // Full SharePoint folder URL
+}
+
+interface UploadResult {
+ success: boolean;
+ webUrl?: string;
+ error?: string;
+}
+
+/**
+ * Parse a SharePoint folder URL to extract site and drive path info
+ * Supports URLs like:
+ * https://tenant.sharepoint.com/sites/SiteName/Documents%20partages/FolderPath
+ * https://tenant.sharepoint.com/:f:/r/sites/SiteName/Documents%20partages/FolderPath?...
+ */
+function parseSharePointUrl(url: string): { siteHostname: string; sitePath: string; folderPath: string } | null {
+ try {
+ const parsed = new URL(url);
+ const hostname = parsed.hostname; // e.g. itinova.sharepoint.com
+
+ // Remove query params and hash
+ let pathname = parsed.pathname;
+
+ // Handle /:f:/r/sites/... format (sharing links)
+ pathname = pathname.replace(/^\/:f:\/r/, '');
+
+ // Decode URL encoding
+ pathname = decodeURIComponent(pathname);
+
+ // Extract site path: /sites/SiteName
+ const siteMatch = pathname.match(/^(\/sites\/[^/]+)/i);
+ if (!siteMatch) return null;
+
+ const sitePath = siteMatch[1]; // e.g. /sites/ITINOVA-27.Factures
+
+ // Everything after the site path is the folder path within the drive
+ const folderPath = pathname.slice(sitePath.length); // e.g. /Documents partages/27. Factures/test
+
+ return { siteHostname: hostname, sitePath, folderPath };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Get an access token using client credentials flow
+ */
+async function getAccessToken(tenantId: string, clientId: string, clientSecret: string): Promise {
+ const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
+ const body = new URLSearchParams({
+ grant_type: 'client_credentials',
+ client_id: clientId,
+ client_secret: clientSecret,
+ scope: 'https://graph.microsoft.com/.default',
+ });
+
+ const response = await fetch(tokenUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Failed to get access token: ${error}`);
+ }
+
+ const data = await response.json() as { access_token: string };
+ return data.access_token;
+}
+
+/**
+ * Upload a file to SharePoint using Microsoft Graph API
+ */
+export async function uploadToSharePoint(
+ config: SharePointConfig,
+ fileBuffer: Buffer,
+ fileName: string
+): Promise {
+ try {
+ const parsed = parseSharePointUrl(config.sharepointUrl);
+ if (!parsed) {
+ return { success: false, error: `URL SharePoint invalide: ${config.sharepointUrl}` };
+ }
+
+ const { siteHostname, sitePath, folderPath } = parsed;
+
+ // Get access token
+ const accessToken = await getAccessToken(config.tenantId, config.clientId, config.clientSecret);
+
+ const graphBase = 'https://graph.microsoft.com/v1.0';
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ };
+
+ // 1. Get the site ID
+ const siteResponse = await fetch(
+ `${graphBase}/sites/${siteHostname}:${sitePath}`,
+ { headers }
+ );
+ if (!siteResponse.ok) {
+ const err = await siteResponse.text();
+ return { success: false, error: `Site SharePoint introuvable (${siteResponse.status}): ${err}` };
+ }
+ const site = await siteResponse.json() as { id: string };
+ const siteId = site.id;
+
+ // 2. Get the default drive
+ const driveResponse = await fetch(`${graphBase}/sites/${siteId}/drive`, { headers });
+ if (!driveResponse.ok) {
+ const err = await driveResponse.text();
+ return { success: false, error: `Drive SharePoint introuvable: ${err}` };
+ }
+ const drive = await driveResponse.json() as { id: string };
+ const driveId = drive.id;
+
+ // 3. Build the folder path for the upload URL
+ // folderPath is like "/Documents partages/27. Factures/test"
+ // We need to encode it properly for Graph API
+ const cleanFolder = folderPath.replace(/^\//, '').replace(/\/$/, '');
+ const encodedFolder = cleanFolder.split('/').map(encodeURIComponent).join('/');
+ const encodedFileName = encodeURIComponent(fileName);
+
+ // 4. Upload the file (simple upload for files < 4MB, resumable for larger)
+ const uploadUrl = fileBuffer.length < 4 * 1024 * 1024
+ ? `${graphBase}/drives/${driveId}/root:/${encodedFolder}/${encodedFileName}:/content`
+ : null;
+
+ if (uploadUrl) {
+ // Simple upload
+ const uploadResponse = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/pdf',
+ },
+ body: new Uint8Array(fileBuffer),
+ });
+
+ if (!uploadResponse.ok) {
+ const err = await uploadResponse.text();
+ return { success: false, error: `Échec de l'upload (${uploadResponse.status}): ${err}` };
+ }
+
+ const uploaded = await uploadResponse.json() as { webUrl?: string };
+ return { success: true, webUrl: uploaded.webUrl };
+ } else {
+ // Resumable upload for large files
+ const createSessionUrl = `${graphBase}/drives/${driveId}/root:/${encodedFolder}/${encodedFileName}:/createUploadSession`;
+ const sessionResponse = await fetch(createSessionUrl, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ item: { '@microsoft.graph.conflictBehavior': 'replace' } }),
+ });
+
+ if (!sessionResponse.ok) {
+ const err = await sessionResponse.text();
+ return { success: false, error: `Échec de création de session d'upload: ${err}` };
+ }
+
+ const session = await sessionResponse.json() as { uploadUrl: string };
+ const chunkSize = 320 * 1024 * 10; // 3.2MB chunks
+ let start = 0;
+
+ while (start < fileBuffer.length) {
+ const end = Math.min(start + chunkSize, fileBuffer.length);
+ const chunk = fileBuffer.slice(start, end);
+ const chunkResponse = await fetch(session.uploadUrl, {
+ method: 'PUT',
+ headers: {
+ 'Content-Range': `bytes ${start}-${end - 1}/${fileBuffer.length}`,
+ 'Content-Length': chunk.length.toString(),
+ },
+ body: new Uint8Array(chunk),
+ });
+
+ if (!chunkResponse.ok && chunkResponse.status !== 202) {
+ const err = await chunkResponse.text();
+ return { success: false, error: `Échec de l'upload du chunk: ${err}` };
+ }
+
+ if (chunkResponse.status === 201 || chunkResponse.status === 200) {
+ const uploaded = await chunkResponse.json() as { webUrl?: string };
+ return { success: true, webUrl: uploaded.webUrl };
+ }
+
+ start = end;
+ }
+
+ return { success: false, error: 'Upload incomplet' };
+ }
+ } catch (error) {
+ return { success: false, error: `Erreur Microsoft Graph: ${error instanceof Error ? error.message : String(error)}` };
+ }
+}