UI Push Deep Links und Drive Diagnose verbessern
All checks were successful
CI / Build and Deploy (push) Successful in 2m30s
All checks were successful
CI / Build and Deploy (push) Successful in 2m30s
This commit is contained in:
@@ -3,23 +3,193 @@ import { Readable } from "node:stream";
|
||||
|
||||
const DEFAULT_DRIVE_FOLDER_ID = "12zMANi_J0uvie16LUxSmfeqwGjKawEhJ";
|
||||
|
||||
function getDriveClient() {
|
||||
const clientEmail = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
|
||||
const privateKey = process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
||||
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";
|
||||
|
||||
if (!clientEmail || !privateKey) {
|
||||
throw new Error("Google-Drive-Service-Account ist nicht konfiguriert.");
|
||||
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: clientEmail,
|
||||
key: privateKey,
|
||||
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")
|
||||
@@ -39,8 +209,8 @@ export async function uploadExpenseProofToDrive(input: {
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
}) {
|
||||
const drive = getDriveClient();
|
||||
const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID || DEFAULT_DRIVE_FOLDER_ID;
|
||||
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}`;
|
||||
@@ -48,17 +218,24 @@ export async function uploadExpenseProofToDrive(input: {
|
||||
const response = await drive.files.create({
|
||||
requestBody: {
|
||||
name,
|
||||
parents: [folderId]
|
||||
parents: [config.folderId]
|
||||
},
|
||||
media: {
|
||||
mimeType: input.mimeType,
|
||||
body: Readable.from(input.buffer)
|
||||
},
|
||||
fields: "id, webViewLink"
|
||||
}).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 Error("Google Drive hat keine Datei-ID zurueckgegeben.");
|
||||
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({
|
||||
@@ -67,6 +244,10 @@ export async function uploadExpenseProofToDrive(input: {
|
||||
type: "anyone",
|
||||
role: "reader"
|
||||
}
|
||||
}).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 {
|
||||
@@ -75,3 +256,61 @@ export async function uploadExpenseProofToDrive(input: {
|
||||
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, webViewLink"
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
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 }).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}`
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user