Changes
This commit is contained in:
@@ -13,6 +13,7 @@ import corsPlugin from "./plugins/cors";
|
|||||||
import resourceRoutes from "./routes/resources";
|
import resourceRoutes from "./routes/resources";
|
||||||
import fastifyCookie from "@fastify/cookie";
|
import fastifyCookie from "@fastify/cookie";
|
||||||
import historyRoutes from "./routes/history";
|
import historyRoutes from "./routes/history";
|
||||||
|
import fileRoutes from "./routes/files";
|
||||||
|
|
||||||
import {sendMail} from "./utils/mailer";
|
import {sendMail} from "./utils/mailer";
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ async function main() {
|
|||||||
await subApp.register(adminRoutes);
|
await subApp.register(adminRoutes);
|
||||||
await subApp.register(resourceRoutes);
|
await subApp.register(resourceRoutes);
|
||||||
await subApp.register(historyRoutes);
|
await subApp.register(historyRoutes);
|
||||||
|
await subApp.register(fileRoutes);
|
||||||
|
|
||||||
},{prefix: "/api"})
|
},{prefix: "/api"})
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
],
|
],
|
||||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allowedHeaders: ["Content-Type", "Authorization", "Context"],
|
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
|
credentials: true, // wichtig, falls du Cookies nutzt
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
240
src/routes/files.ts
Normal file
240
src/routes/files.ts
Normal file
@@ -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" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
const filteredUsers = (users ||[]).filter(i => i.tenants.find((t:any) => t.id === req.user?.tenant_id))
|
||||||
|
|
||||||
|
console.log(filteredUsers)
|
||||||
|
|
||||||
const dataCombined = data.map(historyitem => {
|
const dataCombined = data.map(historyitem => {
|
||||||
return {
|
return {
|
||||||
...historyitem,
|
...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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import {insertHistoryItem } from "../utils/history"
|
import {insertHistoryItem } from "../utils/history"
|
||||||
import {diffObjects} from "../utils/diff";
|
import {diffObjects} from "../utils/diff";
|
||||||
|
import {sortData} from "../utils/sort";
|
||||||
|
|
||||||
const dataTypes: any[] = {
|
const dataTypes: any[] = {
|
||||||
tasks: {
|
tasks: {
|
||||||
@@ -221,6 +222,18 @@ const dataTypes: any[] = {
|
|||||||
labelSingle: "Dokument",
|
labelSingle: "Dokument",
|
||||||
supabaseSelectWithInformation: "*, files(*), statementallocations(*)",
|
supabaseSelectWithInformation: "*, files(*), statementallocations(*)",
|
||||||
},
|
},
|
||||||
|
files: {
|
||||||
|
isArchivable: true,
|
||||||
|
label: "Dateien",
|
||||||
|
labelSingle: "Datei",
|
||||||
|
supabaseSelectWithInformation: "*",
|
||||||
|
},
|
||||||
|
folders: {
|
||||||
|
isArchivable: true,
|
||||||
|
label: "Ordner",
|
||||||
|
labelSingle: "Ordner",
|
||||||
|
supabaseSelectWithInformation: "*",
|
||||||
|
},
|
||||||
incominginvoices: {
|
incominginvoices: {
|
||||||
label: "Eingangsrechnungen",
|
label: "Eingangsrechnungen",
|
||||||
labelSingle: "Eingangsrechnung",
|
labelSingle: "Eingangsrechnung",
|
||||||
@@ -452,18 +465,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
const { resource } = req.params as { resource: string };
|
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
|
const { data, error } = await server.supabase
|
||||||
.from(resource)
|
.from(resource)
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
.select(dataTypes[resource].supabaseSelectWithInformation)
|
.select(select || dataTypes[resource].supabaseSelectWithInformation)
|
||||||
.eq("tenant", req.user.tenant_id)
|
.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) {
|
if (error) {
|
||||||
|
console.log(error)
|
||||||
return reply.code(400).send({ error: error.message });
|
return reply.code(400).send({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return sorted;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detail
|
// Detail
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default async function routes(server: FastifyInstance) {
|
|||||||
tenant_id: body.tenant_id,
|
tenant_id: body.tenant_id,
|
||||||
},
|
},
|
||||||
process.env.JWT_SECRET!,
|
process.env.JWT_SECRET!,
|
||||||
{ expiresIn: "1h" }
|
{ expiresIn: "3h" }
|
||||||
);
|
);
|
||||||
|
|
||||||
reply.setCookie("token", token, {
|
reply.setCookie("token", token, {
|
||||||
|
|||||||
11
src/utils/s3.ts
Normal file
11
src/utils/s3.ts
Normal file
@@ -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
|
||||||
|
})
|
||||||
39
src/utils/sort.ts
Normal file
39
src/utils/sort.ts
Normal file
@@ -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<T extends Record<string, any>>(
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user