diff --git a/src/index.ts b/src/index.ts index c95ded7..c1a4fa7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import corsPlugin from "./plugins/cors"; import resourceRoutes from "./routes/resources"; import fastifyCookie from "@fastify/cookie"; import historyRoutes from "./routes/history"; +import fileRoutes from "./routes/files"; import {sendMail} from "./utils/mailer"; @@ -48,6 +49,7 @@ async function main() { await subApp.register(adminRoutes); await subApp.register(resourceRoutes); await subApp.register(historyRoutes); + await subApp.register(fileRoutes); },{prefix: "/api"}) diff --git a/src/plugins/cors.ts b/src/plugins/cors.ts index 6d806bd..1a0fa5a 100644 --- a/src/plugins/cors.ts +++ b/src/plugins/cors.ts @@ -10,7 +10,7 @@ export default fp(async (server: FastifyInstance) => { ], methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization", "Context"], - exposedHeaders: ["Authorization"], // optional, falls du ihn auch auslesen willst + exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst credentials: true, // wichtig, falls du Cookies nutzt }); }); \ No newline at end of file diff --git a/src/routes/files.ts b/src/routes/files.ts new file mode 100644 index 0000000..4da37fc --- /dev/null +++ b/src/routes/files.ts @@ -0,0 +1,240 @@ +import { FastifyInstance } from "fastify" +import multipart from "@fastify/multipart" +import { s3 } from "../utils/s3" +import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3" +import {getSignedUrl} from "@aws-sdk/s3-request-presigner"; +import archiver from "archiver" + +export default async function fileRoutes(server: FastifyInstance) { + await server.register(multipart,{ + limits: { + fileSize: 20 * 1024 * 1024, // 20 MB + } + }) + + server.post("/files/upload", async (req, reply) => { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) + + const data:any = await req.file() + const fileBuffer = await data.toBuffer() + + console.log(data) + + /*const parts = req.parts() // mehrere FormData-Felder + + console.log(parts) + + let file: any + let meta: any + + for await (const part of parts) { + //console.log(part) + // @ts-ignore + if (part.file) { + file = part + + // @ts-ignore + meta = JSON.parse(part.fields?.meta?.value) + } + }*/ + + let meta = JSON.parse(data.fields?.meta?.value) + + if (!data.file) return reply.code(400).send({ error: "No file uploaded" }) + + console.log("ENDE") + + const {data:createdFileData,error:createdFileError} = await server.supabase + .from("files") + .insert({ + tenant: tenantId, + }) + .select() + .single() + + if(createdFileError) { + console.log(createdFileError) + return reply.code(500).send({ error: "Internal Server Error" }) + } else if(createdFileData && data.file) { + const fileKey = `${tenantId}/filesbyid/${createdFileData.id}/${data.filename}` + + await s3.send(new PutObjectCommand({ + Bucket: process.env.S3_BUCKET || "FEDEO", + Key: fileKey, + Body: fileBuffer, + ContentType: data.mimetype, + })) + + //Update File with Corresponding Path + const {data:updateFileData, error:updateFileError} = await server.supabase + .from("files") + .update({ + ...meta, + path: fileKey, + }) + .eq("id", createdFileData.id) + + if(updateFileError) { + console.log(updateFileError) + return reply.code(500).send({ error: "Internal Server Error" }) + + } else { + /*const {data:tagData, error:tagError} = await server.supabase + .from("filetagmembers") + .insert(tags.map(tag => { + return { + file_id: createdFileData.id, + tag_id: tag + } + }))*/ + + return { id: createdFileData.id, filename: data.filename, path: fileKey } + + } + + } + }) + + server.post("/files/download/:id?", async (req, reply) => { + const { id } = req.params as { id?: string } + + const ids = req.body?.ids || [] + + try { + if (id) { + // 🔹 Einzeldownload + const { data, error } = await server.supabase + .from("files") + .select("*") + .eq("id", id) + .single() + + if (error || !data) { + return reply.code(404).send({ error: "File not found" }) + } + + const command = new GetObjectCommand({ + Bucket: process.env.S3_BUCKET || "FEDEO", + Key: data.path, + }) + + const { Body, ContentType } = await s3.send(command) + + const chunks: any[] = [] + // @ts-ignore + for await (const chunk of Body) { + chunks.push(chunk) + } + const buffer = Buffer.concat(chunks) + + reply.header("Content-Type", ContentType || "application/octet-stream") + reply.header( + "Content-Disposition", + `attachment; filename="${data.path.split("/").pop()}"` + ) + return reply.send(buffer) + } + + console.log(ids) + + if (Array.isArray(ids) && ids.length > 0) { + // 🔹 Multi-Download → ZIP zurückgeben + const { data: supabaseFiles, error } = await server.supabase + .from("files") + .select("*") + .in("id", ids) + + if (error || !supabaseFiles?.length) { + return reply.code(404).send({ error: "Files not found" }) + } + + console.log(supabaseFiles) + + reply.header("Content-Type", "application/zip") + reply.header("Content-Disposition", "attachment; filename=dateien.zip") + + const archive = archiver("zip", { zlib: { level: 9 } }) + archive.on("warning", console.warn) + + for (const entry of supabaseFiles) { + const command = new GetObjectCommand({ + Bucket: process.env.S3_BUCKET || "FEDEO", + Key: entry.path, + }) + + const { Body } = await s3.send(command) + const filename = entry.path.split("/").pop() || entry.id + console.log(filename) + archive.append(Body as any, { name: filename }) + } + + await archive.finalize() + return reply.send(archive) + } + + return reply.code(400).send({ error: "No id or ids provided" }) + } catch (err) { + console.log(err) + reply.code(500).send({ error: "Download failed" }) + } + }) + + server.post("/files/presigned/:id?", async (req, reply) => { + const { id } = req.params as { key: string }; + const { ids } = req.body as { keys: string[] } + + if(id) { + try { + const {data,error} = await server.supabase.from("files").select("*").eq("id", id).single() + + const command = new GetObjectCommand({ + Bucket: process.env.S3_BUCKET || "FEDEO", + Key: data.path, + }); + + // URL für 15 Minuten gültig + const url = await getSignedUrl(s3, command, { expiresIn: 900 }); + + return { ...data, url }; + } catch (err) { + req.log.error(err); + reply.code(500).send({ error: "Could not generate presigned URL" }); + } + } else { + if (!Array.isArray(ids) || ids.length === 0) { + return reply.code(400).send({ error: "No file keys provided" }) + } + + try { + const {data:supabaseFileEntries,error} = await server.supabase.from("files").select("*").in("id", ids) + + + + const urls = await Promise.all( + ids.map(async (id) => { + + let key = supabaseFileEntries.find(i => i.id === id).path + + + const command = new GetObjectCommand({ + Bucket: process.env.S3_BUCKET || "FEDEO", + Key: key, + }) + + const url = await getSignedUrl(s3, command, { expiresIn: 900 }) // 15 min gültig + + return {...supabaseFileEntries.find(i => i.id === id), url} + }) + ) + + return { files: urls } + } catch (err) { + req.log.error(err) + reply.code(500).send({ error: "Could not generate presigned URLs" }) + } + } + + + }) +} \ No newline at end of file diff --git a/src/routes/history.ts b/src/routes/history.ts index e6d396d..2f29ce8 100644 --- a/src/routes/history.ts +++ b/src/routes/history.ts @@ -69,12 +69,12 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) { const filteredUsers = (users ||[]).filter(i => i.tenants.find((t:any) => t.id === req.user?.tenant_id)) - + console.log(filteredUsers) const dataCombined = data.map(historyitem => { return { ...historyitem, - created_by_profile: filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] + created_by_profile: filteredUsers.find(i => i.id === historyitem.created_by) ? filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] : null } }) diff --git a/src/routes/resources.ts b/src/routes/resources.ts index 9b2a2f4..6a7d596 100644 --- a/src/routes/resources.ts +++ b/src/routes/resources.ts @@ -1,6 +1,7 @@ import { FastifyInstance } from "fastify"; import {insertHistoryItem } from "../utils/history" import {diffObjects} from "../utils/diff"; +import {sortData} from "../utils/sort"; const dataTypes: any[] = { tasks: { @@ -221,6 +222,18 @@ const dataTypes: any[] = { labelSingle: "Dokument", supabaseSelectWithInformation: "*, files(*), statementallocations(*)", }, + files: { + isArchivable: true, + label: "Dateien", + labelSingle: "Datei", + supabaseSelectWithInformation: "*", + }, + folders: { + isArchivable: true, + label: "Ordner", + labelSingle: "Ordner", + supabaseSelectWithInformation: "*", + }, incominginvoices: { label: "Eingangsrechnungen", labelSingle: "Eingangsrechnung", @@ -452,18 +465,25 @@ export default async function resourceRoutes(server: FastifyInstance) { const { resource } = req.params as { resource: string }; + const {select, sort, asc } = req.query as { select?: string, sort?: string, asc?: string } + console.log(select, sort, asc) + + const { data, error } = await server.supabase .from(resource) //@ts-ignore - .select(dataTypes[resource].supabaseSelectWithInformation) + .select(select || dataTypes[resource].supabaseSelectWithInformation) .eq("tenant", req.user.tenant_id) - .eq("archived", false); // nur aktive zurückgeben + .eq("archived", false) + + const sorted =sortData(data,sort,asc === "true" ? true : false) if (error) { + console.log(error) return reply.code(400).send({ error: error.message }); } - return data; + return sorted; }); // Detail diff --git a/src/routes/tenant.ts b/src/routes/tenant.ts index fff65a5..24c0c66 100644 --- a/src/routes/tenant.ts +++ b/src/routes/tenant.ts @@ -46,7 +46,7 @@ export default async function routes(server: FastifyInstance) { tenant_id: body.tenant_id, }, process.env.JWT_SECRET!, - { expiresIn: "1h" } + { expiresIn: "3h" } ); reply.setCookie("token", token, { diff --git a/src/utils/s3.ts b/src/utils/s3.ts new file mode 100644 index 0000000..a419fbb --- /dev/null +++ b/src/utils/s3.ts @@ -0,0 +1,11 @@ +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3" + +export const s3 = new S3Client({ + endpoint: process.env.S3_ENDPOINT || "https://fedeo.nbg1.your-objectstorage.com", // z. B. http://localhost:9000 für MinIO + region: process.env.S3_REGION || "eu-central", + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY || "RYOMQRW8KSTY3UQX7RPJ", + secretAccessKey: process.env.S3_SECRET_KEY || "aZ33xBv47sPPsHuFKeHSDiLagjqF7nShnuGkj7B1", + }, + forcePathStyle: true, // wichtig für MinIO +}) \ No newline at end of file diff --git a/src/utils/sort.ts b/src/utils/sort.ts new file mode 100644 index 0000000..1237d62 --- /dev/null +++ b/src/utils/sort.ts @@ -0,0 +1,39 @@ +/** + * Sortiert ein Array von Objekten anhand einer Spalte. + * + * @param data Array von Objekten + * @param column Sortierspalte (Property-Name im Objekt) + * @param ascending true = aufsteigend, false = absteigend + */ +export function sortData>( + data: T[], + column?: keyof T, + ascending: boolean = true +): T[] { + if (!column) return data + + return [...data].sort((a, b) => { + const valA = a[column] + const valB = b[column] + + // null/undefined nach hinten + if (valA == null && valB != null) return 1 + if (valB == null && valA != null) return -1 + if (valA == null && valB == null) return 0 + + // Zahlenvergleich + if (typeof valA === "number" && typeof valB === "number") { + return ascending ? valA - valB : valB - valA + } + + // Datumsvergleich + if (valA instanceof Date && valB instanceof Date) { + return ascending ? valA.getTime() - valB.getTime() : valB.getTime() - valA.getTime() + } + + // Fallback: Stringvergleich + return ascending + ? String(valA).localeCompare(String(valB)) + : String(valB).localeCompare(String(valA)) + }) +} \ No newline at end of file