fix: export SharePoint - erreur remontée au frontend, meilleur parsing URL et détection bibliothèque
This commit is contained in:
@@ -261,9 +261,14 @@ export default function InvoicesBAP() {
|
|||||||
if (data.invoiceId && data.pdfUrl) {
|
if (data.invoiceId && data.pdfUrl) {
|
||||||
setBapPdfUrls(prev => ({ ...prev, [data.invoiceId as number]: data.pdfUrl as string }));
|
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 });
|
toast.success("Facture validée BAP ! Ouverture du PDF annoté...", { duration: 3000 });
|
||||||
window.open(data.pdfUrl, '_blank');
|
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) {
|
} else if (data.exportMode === 'folder' && data.exportPath) {
|
||||||
toast.success(`Facture validée BAP ! PDF enregistré dans : ${data.exportPath}`, { duration: 6000 });
|
toast.success(`Facture validée BAP ! PDF enregistré dans : ${data.exportPath}`, { duration: 6000 });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -635,6 +635,9 @@ export const appRouter = router({
|
|||||||
pdfUrl,
|
pdfUrl,
|
||||||
exportPath,
|
exportPath,
|
||||||
exportMode: bapExportMode,
|
exportMode: bapExportMode,
|
||||||
|
sharepointUploadStatus: sharepointUploadStatus || null,
|
||||||
|
sharepointUploadError: sharepointUploadError || null,
|
||||||
|
sharepointUploadPath: sharepointUploadPath || null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -14,39 +14,47 @@ interface UploadResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
webUrl?: string;
|
webUrl?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
debugInfo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a SharePoint folder URL to extract site and drive path info
|
* Parse a SharePoint folder URL to extract site and drive path info
|
||||||
* Supports URLs like:
|
* Supports URLs like:
|
||||||
* https://tenant.sharepoint.com/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%20partages/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 {
|
function parseSharePointUrl(url: string): { siteHostname: string; sitePath: string; folderPath: string } | null {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const hostname = parsed.hostname; // e.g. itinova.sharepoint.com
|
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;
|
let pathname = parsed.pathname;
|
||||||
|
|
||||||
// Handle /:f:/r/sites/... format (sharing links)
|
// Handle /:f:/r/sites/... format (sharing links)
|
||||||
pathname = pathname.replace(/^\/:f:\/r/, '');
|
pathname = pathname.replace(/^\/:f:\/r/, '');
|
||||||
|
// Handle /:b:/r/ format
|
||||||
|
pathname = pathname.replace(/^\/:b:\/r/, '');
|
||||||
|
|
||||||
// Decode URL encoding
|
// Decode URL encoding
|
||||||
pathname = decodeURIComponent(pathname);
|
pathname = decodeURIComponent(pathname);
|
||||||
|
|
||||||
// Extract site path: /sites/SiteName
|
// Extract site path: /sites/SiteName
|
||||||
const siteMatch = pathname.match(/^(\/sites\/[^/]+)/i);
|
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
|
const sitePath = siteMatch[1]; // e.g. /sites/ITINOVA-27.Factures
|
||||||
|
|
||||||
// Everything after the site path is the folder path within the drive
|
// 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
|
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 };
|
return { siteHostname: hostname, sitePath, folderPath };
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error('[SharePoint] Erreur parsing URL:', e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,10 +82,71 @@ async function getAccessToken(tenantId: string, clientId: string, clientSecret:
|
|||||||
throw new Error(`Failed to get access token: ${error}`);
|
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;
|
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<string, string>,
|
||||||
|
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
|
* Upload a file to SharePoint using Microsoft Graph API
|
||||||
*/
|
*/
|
||||||
@@ -86,16 +155,20 @@ export async function uploadToSharePoint(
|
|||||||
fileBuffer: Buffer,
|
fileBuffer: Buffer,
|
||||||
fileName: string
|
fileName: string
|
||||||
): Promise<UploadResult> {
|
): Promise<UploadResult> {
|
||||||
|
const debugLines: string[] = [];
|
||||||
try {
|
try {
|
||||||
const parsed = parseSharePointUrl(config.sharepointUrl);
|
const parsed = parseSharePointUrl(config.sharepointUrl);
|
||||||
if (!parsed) {
|
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;
|
const { siteHostname, sitePath, folderPath } = parsed;
|
||||||
|
debugLines.push(`Site: ${siteHostname}${sitePath}`);
|
||||||
|
debugLines.push(`Dossier: ${folderPath}`);
|
||||||
|
|
||||||
// Get access token
|
// Get access token
|
||||||
const accessToken = await getAccessToken(config.tenantId, config.clientId, config.clientSecret);
|
const accessToken = await getAccessToken(config.tenantId, config.clientId, config.clientSecret);
|
||||||
|
debugLines.push('Token OAuth2: OK');
|
||||||
|
|
||||||
const graphBase = 'https://graph.microsoft.com/v1.0';
|
const graphBase = 'https://graph.microsoft.com/v1.0';
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -104,39 +177,60 @@ export async function uploadToSharePoint(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1. Get the site ID
|
// 1. Get the site ID
|
||||||
const siteResponse = await fetch(
|
const siteUrl = `${graphBase}/sites/${siteHostname}:${sitePath}`;
|
||||||
`${graphBase}/sites/${siteHostname}:${sitePath}`,
|
console.log('[SharePoint] Récupération site:', siteUrl);
|
||||||
{ headers }
|
const siteResponse = await fetch(siteUrl, { headers });
|
||||||
);
|
|
||||||
if (!siteResponse.ok) {
|
if (!siteResponse.ok) {
|
||||||
const err = await siteResponse.text();
|
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;
|
const siteId = site.id;
|
||||||
|
debugLines.push(`Site ID: ${siteId} (${site.displayName || ''})`);
|
||||||
|
console.log('[SharePoint] Site trouvé:', siteId);
|
||||||
|
|
||||||
// 2. Get the default drive
|
// 2. Find the correct drive and folder path
|
||||||
const driveResponse = await fetch(`${graphBase}/sites/${siteId}/drive`, { headers });
|
const driveInfo = await findDriveAndFolder(graphBase, headers, siteId, folderPath);
|
||||||
if (!driveResponse.ok) {
|
let driveId: string;
|
||||||
const err = await driveResponse.text();
|
let cleanFolderPath: string;
|
||||||
return { success: false, error: `Drive SharePoint introuvable: ${err}` };
|
|
||||||
|
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
|
// 3. Build 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);
|
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)
|
// 4. Upload the file (simple upload for files < 4MB, resumable for larger)
|
||||||
const uploadUrl = fileBuffer.length < 4 * 1024 * 1024
|
if (fileBuffer.length < 4 * 1024 * 1024) {
|
||||||
? `${graphBase}/drives/${driveId}/root:/${encodedFolder}/${encodedFileName}:/content`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (uploadUrl) {
|
|
||||||
// Simple upload
|
// Simple upload
|
||||||
const uploadResponse = await fetch(uploadUrl, {
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -149,14 +243,19 @@ export async function uploadToSharePoint(
|
|||||||
|
|
||||||
if (!uploadResponse.ok) {
|
if (!uploadResponse.ok) {
|
||||||
const err = await uploadResponse.text();
|
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 };
|
const uploaded = await uploadResponse.json() as { webUrl?: string; name?: string };
|
||||||
return { success: true, webUrl: uploaded.webUrl };
|
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 {
|
} else {
|
||||||
// Resumable upload for large files
|
// 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, {
|
const sessionResponse = await fetch(createSessionUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -168,7 +267,7 @@ export async function uploadToSharePoint(
|
|||||||
|
|
||||||
if (!sessionResponse.ok) {
|
if (!sessionResponse.ok) {
|
||||||
const err = await sessionResponse.text();
|
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 };
|
const session = await sessionResponse.json() as { uploadUrl: string };
|
||||||
@@ -189,20 +288,23 @@ export async function uploadToSharePoint(
|
|||||||
|
|
||||||
if (!chunkResponse.ok && chunkResponse.status !== 202) {
|
if (!chunkResponse.ok && chunkResponse.status !== 202) {
|
||||||
const err = await chunkResponse.text();
|
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) {
|
if (chunkResponse.status === 201 || chunkResponse.status === 200) {
|
||||||
const uploaded = await chunkResponse.json() as { webUrl?: string };
|
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;
|
start = end;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, error: 'Upload incomplet' };
|
return { success: false, error: 'Upload incomplet', debugInfo: debugLines.join('\n') };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user