feat: intégration Microsoft Graph pour export SharePoint - helper uploadToSharePoint, champs Azure AD en DB et UI
This commit is contained in:
@@ -46,6 +46,10 @@ export default function ImportSettings() {
|
|||||||
const [exportFolderType, setExportFolderType] = useState<"local" | "teams" | "sharepoint">("local");
|
const [exportFolderType, setExportFolderType] = useState<"local" | "teams" | "sharepoint">("local");
|
||||||
// Compat. ancienne valeur
|
// Compat. ancienne valeur
|
||||||
const [bapExportMode, setBapExportMode] = useState<"browser" | "folder" | "both">("browser");
|
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
|
// Initialize form with settings from database
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,10 +70,19 @@ export default function ImportSettings() {
|
|||||||
setBapExportBrowser(savedMode === "browser" || savedMode === "both");
|
setBapExportBrowser(savedMode === "browser" || savedMode === "both");
|
||||||
setBapExportFolder(savedMode === "folder" || savedMode === "both");
|
setBapExportFolder(savedMode === "folder" || savedMode === "both");
|
||||||
setBapExportMode(savedMode as "browser" | "folder" | "both");
|
setBapExportMode(savedMode as "browser" | "folder" | "both");
|
||||||
// Lire le type de dossier depuis exportFolder (préfixe teams:// ou sharepoint://)
|
// Lire le type de dossier depuis le champ dédié exportFolderType
|
||||||
if ((settings.exportFolder || "").startsWith("teams://")) setExportFolderType("teams");
|
if ((settings as any).exportFolderType) {
|
||||||
else if ((settings.exportFolder || "").startsWith("sharepoint://")) setExportFolderType("sharepoint");
|
setExportFolderType((settings as any).exportFolderType as "local" | "teams" | "sharepoint");
|
||||||
else setExportFolderType("local");
|
} 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]);
|
}, [settings]);
|
||||||
|
|
||||||
@@ -94,7 +107,11 @@ export default function ImportSettings() {
|
|||||||
emailImportPort: emailImportPort,
|
emailImportPort: emailImportPort,
|
||||||
emailImportFrequency: emailImportFrequency,
|
emailImportFrequency: emailImportFrequency,
|
||||||
exportFolder: exportFolder || null,
|
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");
|
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"}
|
{exportFolderType === "sharepoint" && "URL complète du dossier SharePoint de destination"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Champs Azure AD — visibles uniquement pour SharePoint */}
|
||||||
|
{exportFolderType === "sharepoint" && (
|
||||||
|
<div className="mt-4 p-4 bg-cyan-50 dark:bg-cyan-950/20 rounded-xl border border-cyan-200 dark:border-cyan-800 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-cyan-500" />
|
||||||
|
<span className="text-sm font-semibold text-cyan-700 dark:text-cyan-300">Configuration Microsoft Azure AD</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="azureTenantId" className="text-sm font-medium">ID de l'annuaire (Tenant ID) <span className="text-red-500">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="azureTenantId"
|
||||||
|
type="text"
|
||||||
|
placeholder="487d0a81-de35-44ce-8847-03bb74ec553e"
|
||||||
|
value={azureTenantId}
|
||||||
|
onChange={(e) => setAzureTenantId(e.target.value)}
|
||||||
|
className="h-10 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="azureClientId" className="text-sm font-medium">ID d'application (Client ID) <span className="text-red-500">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="azureClientId"
|
||||||
|
type="text"
|
||||||
|
placeholder="e6c2f351-16a6-4659-9b04-de3ea90194f0"
|
||||||
|
value={azureClientId}
|
||||||
|
onChange={(e) => setAzureClientId(e.target.value)}
|
||||||
|
className="h-10 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="azureClientSecret" className="text-sm font-medium">Secret client <span className="text-red-500">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="azureClientSecret"
|
||||||
|
type="password"
|
||||||
|
placeholder="Valeur du secret Azure AD"
|
||||||
|
value={azureClientSecret}
|
||||||
|
onChange={(e) => setAzureClientSecret(e.target.value)}
|
||||||
|
className="h-10 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Stocké de façon sécurisée. Permissions requises : <code>Files.ReadWrite.All</code> et <code>Sites.ReadWrite.All</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
3
drizzle/0024_real_kylun.sql
Normal file
3
drizzle/0024_real_kylun.sql
Normal file
@@ -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;
|
||||||
@@ -185,7 +185,12 @@ export const importSettings = mysqlTable("importSettings", {
|
|||||||
|
|
||||||
// Export folder settings
|
// Export folder settings
|
||||||
exportFolder: text("exportFolder"), // Path to folder for exporting invoices
|
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
|
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
|
// AI Engine settings
|
||||||
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider for invoice extraction
|
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider for invoice extraction
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export const appRouter = router({
|
|||||||
const importSettings = await getImportSettingsByUser(ctx.user.id);
|
const importSettings = await getImportSettingsByUser(ctx.user.id);
|
||||||
const bapExportMode = importSettings?.bapExportMode || 'browser';
|
const bapExportMode = importSettings?.bapExportMode || 'browser';
|
||||||
const exportFolder = importSettings?.exportFolder || null;
|
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 STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
|
||||||
|
|
||||||
let pdfUrl: string | null = null;
|
let pdfUrl: string | null = null;
|
||||||
@@ -550,12 +551,37 @@ export const appRouter = router({
|
|||||||
const filename = path.basename(invoice.fileKey);
|
const filename = path.basename(invoice.fileKey);
|
||||||
const bapFilename = `BAP_${Date.now()}_${filename}`;
|
const bapFilename = `BAP_${Date.now()}_${filename}`;
|
||||||
|
|
||||||
if (bapExportMode === 'folder' && exportFolder) {
|
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
|
||||||
// Mode dossier : enregistrer sur le disque
|
if (exportFolderType === 'sharepoint') {
|
||||||
await fs.mkdir(exportFolder, { recursive: true });
|
// Mode SharePoint : upload via Microsoft Graph
|
||||||
exportPath = path.join(exportFolder, bapFilename);
|
const { uploadToSharePoint } = await import('./sharepoint');
|
||||||
await fs.writeFile(exportPath, signedPdfBytes);
|
const spResult = await uploadToSharePoint(
|
||||||
} else {
|
{
|
||||||
|
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
|
// Mode navigateur : stocker localement et retourner l'URL relative
|
||||||
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
|
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
|
||||||
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
|
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 importSettings = await getImportSettingsByUser(ctx.user.id);
|
||||||
const bapExportMode = importSettings?.bapExportMode || 'browser';
|
const bapExportMode = importSettings?.bapExportMode || 'browser';
|
||||||
const exportFolder = importSettings?.exportFolder || null;
|
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 STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
|
||||||
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
|
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
|
||||||
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
|
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 signedPdfBytes = await pdfDoc.save();
|
||||||
const filename = path.basename(invoice.fileKey);
|
const filename = path.basename(invoice.fileKey);
|
||||||
const bapFilename = `BAP_${Date.now()}_${filename}`;
|
const bapFilename = `BAP_${Date.now()}_${filename}`;
|
||||||
if (bapExportMode === 'folder' && exportFolder) {
|
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
|
||||||
await fs.mkdir(exportFolder, { recursive: true });
|
if (exportFolderType === 'sharepoint') {
|
||||||
exportPath = path.join(exportFolder, bapFilename);
|
const { uploadToSharePoint } = await import('./sharepoint');
|
||||||
await fs.writeFile(exportPath, signedPdfBytes);
|
const spResult = await uploadToSharePoint(
|
||||||
} else {
|
{
|
||||||
|
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 bapKey = generateStorageKey(ctx.user.id, bapFilename);
|
||||||
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
|
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
|
||||||
pdfUrl = url;
|
pdfUrl = url;
|
||||||
@@ -1410,7 +1460,11 @@ export const appRouter = router({
|
|||||||
emailImportPort: 993,
|
emailImportPort: 993,
|
||||||
emailImportFrequency: 30,
|
emailImportFrequency: 30,
|
||||||
exportFolder: null,
|
exportFolder: null,
|
||||||
|
exportFolderType: "local" as const,
|
||||||
bapExportMode: "browser" 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(),
|
emailImportPort: z.number().min(1).max(65535).optional(),
|
||||||
emailImportFrequency: z.number().min(1).optional(),
|
emailImportFrequency: z.number().min(1).optional(),
|
||||||
exportFolder: z.string().nullable().optional(),
|
exportFolder: z.string().nullable().optional(),
|
||||||
|
exportFolderType: z.enum(["local", "teams", "sharepoint"]).optional(),
|
||||||
bapExportMode: z.enum(["browser", "folder", "both"]).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 }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const settings = await upsertImportSettings({
|
const settings = await upsertImportSettings({
|
||||||
|
|||||||
208
server/sharepoint.ts
Normal file
208
server/sharepoint.ts
Normal 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)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user