feat: intégration Microsoft Graph pour export SharePoint - helper uploadToSharePoint, champs Azure AD en DB et UI

This commit is contained in:
Manus
2026-05-06 07:29:23 -04:00
parent 382ac51943
commit 766f2b444d
5 changed files with 353 additions and 16 deletions

View File

@@ -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({

208
server/sharepoint.ts Normal file
View File

@@ -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<string> {
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<UploadResult> {
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)}` };
}
}