From 766f2b444df92c405d44253d0faca8b72240c696 Mon Sep 17 00:00:00 2001 From: Manus Date: Wed, 6 May 2026 07:29:23 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20int=C3=A9gration=20Microsoft=20Graph=20?= =?UTF-8?q?pour=20export=20SharePoint=20-=20helper=20uploadToSharePoint,?= =?UTF-8?q?=20champs=20Azure=20AD=20en=20DB=20et=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/ImportSettings.tsx | 73 +++++++++- drizzle/0024_real_kylun.sql | 3 + drizzle/schema.ts | 5 + server/routers.ts | 80 +++++++++-- server/sharepoint.ts | 208 ++++++++++++++++++++++++++++ 5 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 drizzle/0024_real_kylun.sql create mode 100644 server/sharepoint.ts 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)}` }; + } +}