Files
AnnasRechnungsManager/app/routes/api.einnahmen.$id.upload.ts
hwinkel fab53fc76e
Build and Push Docker Image / build (push) Successful in 1m34s
feat: add receipt upload functionality for Einnahmen and Beleg routes
- 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.
2026-04-29 20:49:57 +02:00

119 lines
3.7 KiB
TypeScript

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 });
}