import { google } from "googleapis"; import { Readable } from "node:stream"; const DEFAULT_DRIVE_FOLDER_ID = "1LV82IZqUu8nRycLFeQl-Rjv5FKrZQqKe"; export type DriveErrorCode = | "DRIVE_CONFIG_MISSING" | "DRIVE_KEY_INVALID" | "DRIVE_AUTH_FAILED" | "DRIVE_FORBIDDEN" | "DRIVE_FOLDER_NOT_FOUND" | "DRIVE_UPLOAD_FAILED" | "DRIVE_PERMISSION_FAILED" | "DRIVE_FILE_ID_MISSING" | "DRIVE_DIAGNOSTIC_CLEANUP_FAILED"; export class DriveIntegrationError extends Error { code: DriveErrorCode; details: string[]; status?: number; constructor(message: string, code: DriveErrorCode, details: string[] = [], status?: number) { super(message); this.name = "DriveIntegrationError"; this.code = code; this.details = details; this.status = status; } } type DriveConfig = { clientEmail: string; privateKey: string; folderId: string; }; function getGoogleErrorStatus(error: unknown) { if (typeof error !== "object" || !error) { return undefined; } if ("code" in error && typeof error.code === "number") { return error.code; } if ("status" in error && typeof error.status === "number") { return error.status; } return undefined; } function getGoogleErrorReason(error: unknown) { if (typeof error !== "object" || !error) { return null; } const response = "response" in error && typeof error.response === "object" && error.response ? error.response : null; const data = response && "data" in response && typeof response.data === "object" && response.data ? response.data : null; if (data && "error_description" in data && typeof data.error_description === "string") { return data.error_description; } if (data && "error" in data && typeof data.error === "string") { return data.error; } if ("message" in error && typeof error.message === "string") { return error.message; } return null; } function mapDriveError(error: unknown, fallbackCode: DriveErrorCode, fallbackMessage: string, details: string[] = []) { if (error instanceof DriveIntegrationError) { return error; } const status = getGoogleErrorStatus(error); const reason = getGoogleErrorReason(error); const nextDetails = [...details]; if (status) { nextDetails.push(`Google API Status: ${status}`); } if (reason) { nextDetails.push(`Google API Meldung: ${reason}`); } if (status === 401 || reason?.includes("invalid_grant")) { return new DriveIntegrationError( "Google Drive konnte den Service Account nicht authentifizieren.", "DRIVE_AUTH_FAILED", [ "Prüfe, ob GOOGLE_SERVICE_ACCOUNT_EMAIL zur heruntergeladenen JSON-Key-Datei passt.", "Prüfe, ob GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY vollständig und mit \\n-Zeilenumbrüchen gesetzt ist.", ...nextDetails ], status ); } if (status === 403) { return new DriveIntegrationError( "Google Drive lehnt den Zugriff ab.", "DRIVE_FORBIDDEN", [ "Prüfe, ob die Google Drive API im Google-Cloud-Projekt aktiviert ist.", "Prüfe, ob der Zielordner für die Service-Account-Mail freigegeben wurde.", ...nextDetails ], status ); } if (status === 404) { return new DriveIntegrationError( "Google Drive findet den Zielordner nicht.", "DRIVE_FOLDER_NOT_FOUND", [ "Prüfe GOOGLE_DRIVE_FOLDER_ID.", "Prüfe, ob der Ordner für die Service-Account-Mail sichtbar ist.", ...nextDetails ], status ); } return new DriveIntegrationError(fallbackMessage, fallbackCode, nextDetails, status); } export function getDriveConfig(): DriveConfig { const clientEmail = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL?.trim(); const rawPrivateKey = process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY; const privateKey = rawPrivateKey?.replace(/\\n/g, "\n"); const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID?.trim() || DEFAULT_DRIVE_FOLDER_ID; const missing = [ !clientEmail ? "GOOGLE_SERVICE_ACCOUNT_EMAIL" : null, !rawPrivateKey ? "GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY" : null, !folderId ? "GOOGLE_DRIVE_FOLDER_ID" : null ].filter(Boolean); if (missing.length > 0 || !clientEmail || !privateKey) { throw new DriveIntegrationError("Google Drive ist nicht vollständig konfiguriert.", "DRIVE_CONFIG_MISSING", [ `Fehlende Werte: ${missing.join(", ")}`, "Der Private Key wird nicht ausgegeben, damit keine Secrets im Browser landen." ]); } if (!clientEmail.includes("@") || !clientEmail.includes(".iam.gserviceaccount.com")) { throw new DriveIntegrationError("Die Service-Account-Mail sieht ungültig aus.", "DRIVE_CONFIG_MISSING", [ "GOOGLE_SERVICE_ACCOUNT_EMAIL sollte ungefähr so aussehen: name@projekt.iam.gserviceaccount.com" ]); } if (!privateKey.includes("-----BEGIN PRIVATE KEY-----") || !privateKey.includes("-----END PRIVATE KEY-----")) { throw new DriveIntegrationError("Der Google-Service-Account-Key hat kein gültiges Private-Key-Format.", "DRIVE_KEY_INVALID", [ "GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY muss den kompletten Private Key enthalten.", "In .env sollte der Key in Anführungszeichen stehen und Zeilenumbrüche als \\n enthalten.", "Beispiel: \"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n\"" ]); } return { clientEmail, privateKey, folderId }; } function getDriveClient(config = getDriveConfig()) { const auth = new google.auth.JWT({ email: config.clientEmail, key: config.privateKey, scopes: ["https://www.googleapis.com/auth/drive.file"] }); return google.drive({ version: "v3", auth }); } export function serializeDriveError(error: unknown) { const driveError = error instanceof DriveIntegrationError ? error : mapDriveError(error, "DRIVE_UPLOAD_FAILED", "Google Drive konnte die Anfrage nicht verarbeiten."); return { error: driveError.message, code: driveError.code, details: driveError.details, status: driveError.status }; } export function sanitizeDriveFileName(title: string, fallback = "beleg") { const sanitized = title .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-zA-Z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 80); return sanitized || fallback; } export async function uploadExpenseProofToDrive(input: { title: string; invoiceDate: string; sequence: number; fileName: string; mimeType: string; buffer: Buffer; }) { const config = getDriveConfig(); const drive = getDriveClient(config); const extension = input.fileName.includes(".") ? `.${input.fileName.split(".").pop()}` : ""; const baseName = sanitizeDriveFileName(input.title); const name = `${input.invoiceDate}-${baseName}-${String(input.sequence).padStart(2, "0")}${extension}`; const response = await drive.files.create({ requestBody: { name, parents: [config.folderId] }, media: { mimeType: input.mimeType, body: Readable.from(input.buffer) }, fields: "id, name, webViewLink, webContentLink", supportsAllDrives: true }).catch((error: unknown) => { throw mapDriveError(error, "DRIVE_UPLOAD_FAILED", "Google Drive konnte den Rechnungsbeleg nicht hochladen.", [ `Zielordner: ${config.folderId}`, `Service Account: ${config.clientEmail}` ]); }); if (!response.data.id) { throw new DriveIntegrationError("Google Drive hat keine Datei-ID zurückgegeben.", "DRIVE_FILE_ID_MISSING", [ "Der Upload wurde von Google angenommen, aber ohne Datei-ID beantwortet." ]); } await drive.permissions.create({ fileId: response.data.id, requestBody: { type: "anyone", role: "reader" }, supportsAllDrives: true }).catch((error: unknown) => { throw mapDriveError(error, "DRIVE_PERMISSION_FAILED", "Google Drive konnte den Freigabe-Link nicht erstellen.", [ `Die Datei wurde vermutlich bereits erstellt. Drive-Datei-ID: ${response.data.id}` ]); }); return { driveFileId: response.data.id, proofUrl: response.data.webViewLink ?? `https://drive.google.com/file/d/${response.data.id}/view`, storedFileName: name }; } export async function runDriveDiagnostics() { const config = getDriveConfig(); const drive = getDriveClient(config); const testName = `rfp-drive-api-test-${new Date().toISOString().replace(/[:.]/g, "-")}.txt`; let createdFileId: string | null = null; try { const response = await drive.files.create({ requestBody: { name: testName, parents: [config.folderId] }, media: { mimeType: "text/plain", body: Readable.from(Buffer.from("RFP Finanzen Drive API Test\n", "utf8")) }, fields: "id, name, webViewLink, webContentLink", supportsAllDrives: true }); createdFileId = response.data.id ?? null; if (!createdFileId) { throw new DriveIntegrationError("Google Drive hat für die Testdatei keine Datei-ID zurückgegeben.", "DRIVE_FILE_ID_MISSING"); } await drive.files.delete({ fileId: createdFileId, supportsAllDrives: true }); return { ok: true, folderId: config.folderId, serviceAccountEmail: config.clientEmail, details: [ "Service-Account-Konfiguration ist vollständig.", "Authentifizierung bei Google war erfolgreich.", "Eine Testdatei konnte im Zielordner erstellt und wieder gelöscht werden." ] }; } catch (error) { if (createdFileId) { await drive.files.delete({ fileId: createdFileId, supportsAllDrives: true }).catch((cleanupError: unknown) => { throw new DriveIntegrationError( "Drive-Test ist fehlgeschlagen und die temporäre Testdatei konnte nicht gelöscht werden.", "DRIVE_DIAGNOSTIC_CLEANUP_FAILED", [ `Temporäre Drive-Datei-ID: ${createdFileId}`, ...serializeDriveError(cleanupError).details ] ); }); } throw mapDriveError(error, "DRIVE_UPLOAD_FAILED", "Der Google-Drive-Verbindungstest ist fehlgeschlagen.", [ `Zielordner: ${config.folderId}`, `Service Account: ${config.clientEmail}` ]); } }