feat: add receipt upload functionality for Einnahmen and Beleg routes
Build and Push Docker Image / build (push) Successful in 1m34s
Build and Push Docker Image / build (push) Successful in 1m34s
- Implemented upload and retrieval of receipts (Belege) associated with Einnahmen entries. - Added new API routes for uploading and deleting receipts. - Updated the Einnahmen model to include a `belegUrl` field for storing receipt references. - Enhanced the Einnahmen page to support file uploads and display existing receipts. - Introduced drag-and-drop functionality for file uploads and improved user feedback during uploads. - Added necessary validation for file types and sizes during uploads.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { log } from "@/lib/logger.server";
|
||||
import { writeFile, mkdir, unlink } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
const ALLOWED_MIME: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"application/pdf": "pdf",
|
||||
};
|
||||
|
||||
/** Absolute path to the document storage root (configurable via env). */
|
||||
function storageRoot(): string {
|
||||
return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents");
|
||||
}
|
||||
|
||||
/** belegUrl format stored in DB: "beleg:{userId}/{filename}" */
|
||||
function parseBelegPath(belegUrl: string | null): string | null {
|
||||
if (!belegUrl?.startsWith("beleg:")) return null;
|
||||
return belegUrl.slice("beleg:".length); // "{userId}/{filename}"
|
||||
}
|
||||
|
||||
async function removeUploadedFile(belegUrl: string | null) {
|
||||
const rel = parseBelegPath(belegUrl);
|
||||
if (!rel) return;
|
||||
try {
|
||||
await unlink(join(storageRoot(), rel));
|
||||
} catch {
|
||||
// File may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string };
|
||||
}) {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const buchung = await prisma.buchung.findFirst({
|
||||
where: { id: params.id, company: { userId: user.id }, isBusinessRecord: true },
|
||||
select: { id: true, companyId: true, belegUrl: true },
|
||||
});
|
||||
if (!buchung) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// DELETE — remove beleg
|
||||
if (request.method === "DELETE") {
|
||||
await removeUploadedFile(buchung.belegUrl);
|
||||
await prisma.buchung.update({ where: { id: params.id }, data: { belegUrl: null } });
|
||||
await log({
|
||||
userId: user.id,
|
||||
action: "DELETE_BELEG",
|
||||
entity: "Buchung",
|
||||
entityId: params.id,
|
||||
request,
|
||||
});
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
if (request.method !== "POST") {
|
||||
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
// POST — upload new beleg
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return Response.json({ error: "Ungültige Formulardaten" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return Response.json({ error: "Keine Datei angegeben" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_SIZE_BYTES) {
|
||||
return Response.json({ error: "Datei zu groß (max. 10 MB)" }, { status: 413 });
|
||||
}
|
||||
const ext = ALLOWED_MIME[file.type];
|
||||
if (!ext) {
|
||||
return Response.json(
|
||||
{ error: "Dateityp nicht erlaubt (erlaubt: PDF, JPG, PNG, WebP, GIF)" },
|
||||
{ status: 415 }
|
||||
);
|
||||
}
|
||||
|
||||
// Replace old file if present
|
||||
await removeUploadedFile(buchung.belegUrl);
|
||||
|
||||
const safeName = `${buchung.id}-${Date.now()}.${ext}`;
|
||||
const userDir = join(storageRoot(), user.id);
|
||||
await mkdir(userDir, { recursive: true });
|
||||
await writeFile(join(userDir, safeName), Buffer.from(await file.arrayBuffer()));
|
||||
|
||||
// Store as "beleg:{userId}/{storedName}|{originalName}"
|
||||
// storedName is used for serving; originalName is preserved for display
|
||||
const originalName = file.name;
|
||||
const belegUrl = `beleg:${user.id}/${safeName}|${originalName}`;
|
||||
await prisma.buchung.update({ where: { id: params.id }, data: { belegUrl } });
|
||||
|
||||
await log({
|
||||
userId: user.id,
|
||||
action: "UPLOAD_BELEG",
|
||||
entity: "Buchung",
|
||||
entityId: params.id,
|
||||
metadata: { filename: file.name, size: file.size, type: file.type },
|
||||
request,
|
||||
});
|
||||
|
||||
return Response.json({ belegUrl, originalName: file.name, size: file.size });
|
||||
}
|
||||
Reference in New Issue
Block a user