diff --git a/client/src/pages/InvoicesBAP.tsx b/client/src/pages/InvoicesBAP.tsx index 0187713..105b36f 100644 --- a/client/src/pages/InvoicesBAP.tsx +++ b/client/src/pages/InvoicesBAP.tsx @@ -261,9 +261,14 @@ export default function InvoicesBAP() { if (data.invoiceId && data.pdfUrl) { setBapPdfUrls(prev => ({ ...prev, [data.invoiceId as number]: data.pdfUrl as string })); } - if (data.exportMode === 'browser' && data.pdfUrl) { + // Afficher l'erreur SharePoint si présente + if ((data as any).sharepointUploadStatus === 'error') { + toast.error(`Erreur export SharePoint : ${(data as any).sharepointUploadError || 'Erreur inconnue'}`, { duration: 8000 }); + } else if (data.exportMode === 'browser' && data.pdfUrl) { toast.success("Facture validée BAP ! Ouverture du PDF annoté...", { duration: 3000 }); window.open(data.pdfUrl, '_blank'); + } else if ((data as any).sharepointUploadStatus === 'success' && (data as any).sharepointUploadPath) { + toast.success(`Facture validée BAP ! PDF déposé dans SharePoint`, { duration: 6000 }); } else if (data.exportMode === 'folder' && data.exportPath) { toast.success(`Facture validée BAP ! PDF enregistré dans : ${data.exportPath}`, { duration: 6000 }); } else { diff --git a/server/routers.ts b/server/routers.ts index a528223..015dc4c 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -635,6 +635,9 @@ export const appRouter = router({ pdfUrl, exportPath, exportMode: bapExportMode, + sharepointUploadStatus: sharepointUploadStatus || null, + sharepointUploadError: sharepointUploadError || null, + sharepointUploadPath: sharepointUploadPath || null, }; }), diff --git a/server/sharepoint.ts b/server/sharepoint.ts index 75fa7e6..a484f20 100644 --- a/server/sharepoint.ts +++ b/server/sharepoint.ts @@ -14,39 +14,47 @@ interface UploadResult { success: boolean; webUrl?: string; error?: string; + debugInfo?: 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?... + * https://tenant.sharepoint.com/sites/SiteName/Documents%20partag%C3%A9s/FolderPath + * https://tenant.sharepoint.com/:f:/r/sites/SiteName/Documents%20partag%C3%A9s/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 + // Remove query params and hash, keep only pathname let pathname = parsed.pathname; // Handle /:f:/r/sites/... format (sharing links) pathname = pathname.replace(/^\/:f:\/r/, ''); + // Handle /:b:/r/ format + pathname = pathname.replace(/^\/:b:\/r/, ''); // Decode URL encoding pathname = decodeURIComponent(pathname); // Extract site path: /sites/SiteName const siteMatch = pathname.match(/^(\/sites\/[^/]+)/i); - if (!siteMatch) return null; + if (!siteMatch) { + console.warn('[SharePoint] Impossible de trouver /sites/ dans le chemin:', pathname); + 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 + console.log('[SharePoint] URL parsée:', { hostname, sitePath, folderPath }); return { siteHostname: hostname, sitePath, folderPath }; - } catch { + } catch (e) { + console.error('[SharePoint] Erreur parsing URL:', e); return null; } } @@ -74,10 +82,71 @@ async function getAccessToken(tenantId: string, clientId: string, clientSecret: throw new Error(`Failed to get access token: ${error}`); } - const data = await response.json() as { access_token: string }; + const data = await response.json() as { access_token: string; error?: string; error_description?: string }; + if ((data as any).error) { + throw new Error(`Token error: ${(data as any).error_description || (data as any).error}`); + } return data.access_token; } +/** + * Find the correct drive (document library) that contains the given folder path + * SharePoint sites can have multiple drives (Shared Documents, Documents partages, etc.) + */ +async function findDriveAndFolder( + graphBase: string, + headers: Record, + siteId: string, + folderPath: string +): Promise<{ driveId: string; cleanFolderPath: string } | null> { + // Get all drives for the site + const drivesResponse = await fetch(`${graphBase}/sites/${siteId}/drives`, { headers }); + if (!drivesResponse.ok) { + console.warn('[SharePoint] Impossible de lister les drives:', await drivesResponse.text()); + return null; + } + const drivesData = await drivesResponse.json() as { value: Array<{ id: string; name: string; webUrl: string }> }; + const drives = drivesData.value || []; + console.log('[SharePoint] Drives disponibles:', drives.map(d => ({ id: d.id, name: d.name }))); + + // Le folderPath commence par le nom de la bibliothèque de documents + // ex: "/Documents partages/27. Factures/test" → bibliothèque "Documents partages", sous-dossier "27. Factures/test" + // ex: "/Documents partag\u00e9s/..." → même chose avec accent + const pathParts = folderPath.replace(/^\//, '').split('/'); + const libraryName = pathParts[0]; // premier segment = nom de la bibliothèque + const subFolderPath = pathParts.slice(1).join('/'); // reste = sous-dossiers + + console.log('[SharePoint] Recherche bibliothèque:', libraryName, '| Sous-dossier:', subFolderPath); + + // Chercher le drive qui correspond au nom de la bibliothèque (insensible à la casse et aux accents) + const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + const normalizedLibrary = normalize(libraryName); + + let matchedDrive = drives.find(d => normalize(d.name) === normalizedLibrary); + + // Si pas trouvé par nom exact, essayer "Documents" (bibliothèque par défaut) + if (!matchedDrive) { + matchedDrive = drives.find(d => + normalize(d.name).includes('document') || + normalize(d.name) === 'documents' + ); + console.log('[SharePoint] Bibliothèque non trouvée par nom exact, fallback sur:', matchedDrive?.name); + } + + if (!matchedDrive) { + // Utiliser le premier drive disponible + matchedDrive = drives[0]; + console.log('[SharePoint] Utilisation du premier drive:', matchedDrive?.name); + } + + if (!matchedDrive) return null; + + return { + driveId: matchedDrive.id, + cleanFolderPath: subFolderPath, + }; +} + /** * Upload a file to SharePoint using Microsoft Graph API */ @@ -86,16 +155,20 @@ export async function uploadToSharePoint( fileBuffer: Buffer, fileName: string ): Promise { + const debugLines: string[] = []; try { const parsed = parseSharePointUrl(config.sharepointUrl); if (!parsed) { - return { success: false, error: `URL SharePoint invalide: ${config.sharepointUrl}` }; + return { success: false, error: `URL SharePoint invalide ou non reconnue: ${config.sharepointUrl}` }; } const { siteHostname, sitePath, folderPath } = parsed; + debugLines.push(`Site: ${siteHostname}${sitePath}`); + debugLines.push(`Dossier: ${folderPath}`); // Get access token const accessToken = await getAccessToken(config.tenantId, config.clientId, config.clientSecret); + debugLines.push('Token OAuth2: OK'); const graphBase = 'https://graph.microsoft.com/v1.0'; const headers = { @@ -104,39 +177,60 @@ export async function uploadToSharePoint( }; // 1. Get the site ID - const siteResponse = await fetch( - `${graphBase}/sites/${siteHostname}:${sitePath}`, - { headers } - ); + const siteUrl = `${graphBase}/sites/${siteHostname}:${sitePath}`; + console.log('[SharePoint] Récupération site:', siteUrl); + const siteResponse = await fetch(siteUrl, { headers }); if (!siteResponse.ok) { const err = await siteResponse.text(); - return { success: false, error: `Site SharePoint introuvable (${siteResponse.status}): ${err}` }; + debugLines.push(`Erreur site (${siteResponse.status}): ${err.slice(0, 200)}`); + return { success: false, error: `Site SharePoint introuvable (${siteResponse.status}): ${err.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; } - const site = await siteResponse.json() as { id: string }; + const site = await siteResponse.json() as { id: string; displayName?: string }; const siteId = site.id; + debugLines.push(`Site ID: ${siteId} (${site.displayName || ''})`); + console.log('[SharePoint] Site trouvé:', siteId); - // 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}` }; + // 2. Find the correct drive and folder path + const driveInfo = await findDriveAndFolder(graphBase, headers, siteId, folderPath); + let driveId: string; + let cleanFolderPath: string; + + if (driveInfo) { + driveId = driveInfo.driveId; + cleanFolderPath = driveInfo.cleanFolderPath; + debugLines.push(`Drive ID: ${driveId}`); + debugLines.push(`Chemin dans le drive: ${cleanFolderPath || '(racine)'}`); + } else { + // Fallback: use 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.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; + } + const drive = await driveResponse.json() as { id: string }; + driveId = drive.id; + // Use full folderPath without leading slash + cleanFolderPath = folderPath.replace(/^\//, '').replace(/\/$/, ''); + debugLines.push(`Drive ID (fallback): ${driveId}`); } - 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('/'); + // 3. Build upload URL const encodedFileName = encodeURIComponent(fileName); + let uploadUrl: string; + + if (cleanFolderPath) { + const encodedFolder = cleanFolderPath.split('/').map(encodeURIComponent).join('/'); + uploadUrl = `${graphBase}/drives/${driveId}/root:/${encodedFolder}/${encodedFileName}:/content`; + } else { + // Upload to root of drive + uploadUrl = `${graphBase}/drives/${driveId}/root:/${encodedFileName}:/content`; + } + + console.log('[SharePoint] URL upload:', uploadUrl); + debugLines.push(`URL upload: ${uploadUrl}`); // 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) { + if (fileBuffer.length < 4 * 1024 * 1024) { // Simple upload const uploadResponse = await fetch(uploadUrl, { method: 'PUT', @@ -149,14 +243,19 @@ export async function uploadToSharePoint( if (!uploadResponse.ok) { const err = await uploadResponse.text(); - return { success: false, error: `Échec de l'upload (${uploadResponse.status}): ${err}` }; + debugLines.push(`Erreur upload (${uploadResponse.status}): ${err.slice(0, 300)}`); + console.error('[SharePoint] Erreur upload:', uploadResponse.status, err.slice(0, 500)); + return { success: false, error: `Échec de l'upload SharePoint (${uploadResponse.status}): ${err.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; } - const uploaded = await uploadResponse.json() as { webUrl?: string }; - return { success: true, webUrl: uploaded.webUrl }; + const uploaded = await uploadResponse.json() as { webUrl?: string; name?: string }; + console.log('[SharePoint] Upload réussi:', uploaded.webUrl); + debugLines.push(`Fichier déposé: ${uploaded.webUrl}`); + return { success: true, webUrl: uploaded.webUrl, debugInfo: debugLines.join('\n') }; + } else { // Resumable upload for large files - const createSessionUrl = `${graphBase}/drives/${driveId}/root:/${encodedFolder}/${encodedFileName}:/createUploadSession`; + const createSessionUrl = uploadUrl.replace(':/content', ':/createUploadSession'); const sessionResponse = await fetch(createSessionUrl, { method: 'POST', headers: { @@ -168,7 +267,7 @@ export async function uploadToSharePoint( if (!sessionResponse.ok) { const err = await sessionResponse.text(); - return { success: false, error: `Échec de création de session d'upload: ${err}` }; + return { success: false, error: `Échec de création de session d'upload: ${err.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; } const session = await sessionResponse.json() as { uploadUrl: string }; @@ -189,20 +288,23 @@ export async function uploadToSharePoint( if (!chunkResponse.ok && chunkResponse.status !== 202) { const err = await chunkResponse.text(); - return { success: false, error: `Échec de l'upload du chunk: ${err}` }; + return { success: false, error: `Échec de l'upload du chunk: ${err.slice(0, 300)}`, debugInfo: debugLines.join('\n') }; } if (chunkResponse.status === 201 || chunkResponse.status === 200) { const uploaded = await chunkResponse.json() as { webUrl?: string }; - return { success: true, webUrl: uploaded.webUrl }; + return { success: true, webUrl: uploaded.webUrl, debugInfo: debugLines.join('\n') }; } start = end; } - return { success: false, error: 'Upload incomplet' }; + return { success: false, error: 'Upload incomplet', debugInfo: debugLines.join('\n') }; } } catch (error) { - return { success: false, error: `Erreur Microsoft Graph: ${error instanceof Error ? error.message : String(error)}` }; + const msg = error instanceof Error ? error.message : String(error); + console.error('[SharePoint] Exception:', msg); + debugLines.push(`Exception: ${msg}`); + return { success: false, error: `Erreur Microsoft Graph: ${msg}`, debugInfo: debugLines.join('\n') }; } }