From 5d3cdeb960e14db3bead273cc1f5c45ec4fa84d3 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 27 Oct 2025 17:39:25 +0100 Subject: [PATCH] Added Paginated Endpoint Reformatted Code --- src/routes/resources.ts | 262 ++++++++++++++++++++++++++++++++-------- 1 file changed, 209 insertions(+), 53 deletions(-) diff --git a/src/routes/resources.ts b/src/routes/resources.ts index 5bba9b3..e60eea7 100644 --- a/src/routes/resources.ts +++ b/src/routes/resources.ts @@ -1,5 +1,5 @@ -import { FastifyInstance } from "fastify"; -import {insertHistoryItem } from "../utils/history" +import {FastifyInstance} from "fastify"; +import {insertHistoryItem} from "../utils/history" import {diffObjects} from "../utils/diff"; import {sortData} from "../utils/sort"; import {useNextNumberRangeNumber} from "../utils/functions"; @@ -26,7 +26,7 @@ const dataTypes: any[] = { label: "Kunden", labelSingle: "Kunde", isStandardEntity: true, - redirect:true, + redirect: true, numberRangeHolder: "customerNumber", historyItemHolder: "customer", supabaseSortColumn: "customerNumber", @@ -35,17 +35,17 @@ const dataTypes: any[] = { "Allgemeines", "Kontaktdaten" ], - showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'}] + showTabs: [{label: 'Informationen'}, {label: 'Ansprechpartner'}, {label: 'Dateien'}, {label: 'Ausgangsbelege'}, {label: 'Projekte'}, {label: 'Objekte'}, {label: 'Termine'}, {label: 'Verträge'}] }, contacts: { isArchivable: true, label: "Kontakte", labelSingle: "Kontakt", isStandardEntity: true, - redirect:true, + redirect: true, historyItemHolder: "contact", supabaseSelectWithInformation: "*, customer(*), vendor(*)", - showTabs:[ + showTabs: [ { label: 'Informationen', } @@ -57,24 +57,24 @@ const dataTypes: any[] = { labelSingle: "Vertrag", isStandardEntity: true, numberRangeHolder: "contractNumber", - redirect:true, + redirect: true, inputColumns: [ "Allgemeines", "Abrechnung" ], supabaseSelectWithInformation: "*, customer(*), files(*)", - showTabs: [{label: 'Informationen'},{label: 'Dateien'}] + showTabs: [{label: 'Informationen'}, {label: 'Dateien'}] }, absencerequests: { isArchivable: true, label: "Abwesenheiten", labelSingle: "Abwesenheit", isStandardEntity: true, - supabaseSortColumn:"startDate", + supabaseSortColumn: "startDate", supabaseSortAscending: false, supabaseSelectWithInformation: "*", historyItemHolder: "absencerequest", - redirect:true, + redirect: true, showTabs: [{label: 'Informationen'}] }, plants: { @@ -82,17 +82,17 @@ const dataTypes: any[] = { label: "Objekte", labelSingle: "Objekt", isStandardEntity: true, - redirect:true, + redirect: true, historyItemHolder: "plant", supabaseSelectWithInformation: "*, customer(id,name)", showTabs: [ { label: "Informationen" - },{ + }, { label: "Projekte" - },{ + }, { label: "Aufgaben" - },{ + }, { label: "Dateien" }] }, @@ -101,7 +101,7 @@ const dataTypes: any[] = { label: "Artikel", labelSingle: "Artikel", isStandardEntity: true, - redirect:true, + redirect: true, supabaseSelectWithInformation: "*, unit(name)", historyItemHolder: "product", showTabs: [ @@ -115,7 +115,7 @@ const dataTypes: any[] = { label: "Projekte", labelSingle: "Projekt", isStandardEntity: true, - redirect:true, + redirect: true, historyItemHolder: "project", numberRangeHolder: "projectNumber", supabaseSelectWithInformation: "*, customer(id,name), plant(id,name), projecttype(name, id), tasks(*, project(id,name), customer(id,name), plant(id,name)), files(*), createddocuments(*, statementallocations(*)), events(*), times(*, profile(id, fullName))", @@ -128,17 +128,17 @@ const dataTypes: any[] = { { key: "phases", label: "Phasen" - },{ + }, { key: "tasks", label: "Aufgaben" - },{ + }, { key: "files", label: "Dateien" - },{ + }, { label: "Zeiten" - },{ + }, { label: "Ausgangsbelege" - },{ + }, { label: "Termine" }/*,{ key: "timetracking", @@ -156,7 +156,7 @@ const dataTypes: any[] = { label: "Fahrzeuge", labelSingle: "Fahrzeug", isStandardEntity: true, - redirect:true, + redirect: true, historyItemHolder: "vehicle", supabaseSelectWithInformation: "*, checks(*), files(*)", showTabs: [ @@ -174,7 +174,7 @@ const dataTypes: any[] = { label: "Lieferanten", labelSingle: "Lieferant", isStandardEntity: true, - redirect:true, + redirect: true, numberRangeHolder: "vendorNumber", historyItemHolder: "vendor", supabaseSortColumn: "vendorNumber", @@ -182,7 +182,7 @@ const dataTypes: any[] = { showTabs: [ { label: 'Informationen', - },{ + }, { label: 'Ansprechpartner', }, { label: 'Dateien', @@ -212,7 +212,7 @@ const dataTypes: any[] = { label: 'Informationen', }, { label: 'Dateien', - },{label: 'Inventarartikel'} + }, {label: 'Inventarartikel'} ] }, users: { @@ -240,7 +240,7 @@ const dataTypes: any[] = { incominginvoices: { label: "Eingangsrechnungen", labelSingle: "Eingangsrechnung", - redirect:true + redirect: true }, inventoryitems: { isArchivable: true, @@ -335,7 +335,8 @@ const dataTypes: any[] = { redirect: true, showTabs: [ { - label: 'Informationen',} + label: 'Informationen', + } ] }, profiles: { @@ -421,7 +422,7 @@ const dataTypes: any[] = { roles: { label: "Rollen", labelSingle: "Rolle", - redirect:true, + redirect: true, historyItemHolder: "role", filters: [], templateColumns: [ @@ -439,23 +440,23 @@ const dataTypes: any[] = { label: "Kostenstellen", labelSingle: "Kostenstelle", isStandardEntity: true, - redirect:true, + redirect: true, numberRangeHolder: "number", historyItemHolder: "costcentre", supabaseSortColumn: "number", supabaseSelectWithInformation: "*, project(*), vehicle(*), inventoryitem(*)", - showTabs: [{label: 'Informationen'},{label: 'Auswertung Kostenstelle'}] + showTabs: [{label: 'Informationen'}, {label: 'Auswertung Kostenstelle'}] }, ownaccounts: { isArchivable: true, label: "zusätzliche Buchungskonten", labelSingle: "zusätzliches Buchungskonto", isStandardEntity: true, - redirect:true, + redirect: true, historyItemHolder: "ownaccount", supabaseSortColumn: "number", supabaseSelectWithInformation: "*, statementallocations(*, bs_id(*))", - showTabs: [{label: 'Informationen'},{label: 'Buchungen'}] + showTabs: [{label: 'Informationen'}, {label: 'Buchungen'}] }, tickets: { isArchivable: true, @@ -472,7 +473,8 @@ const dataTypes: any[] = { } export default async function resourceRoutes(server: FastifyInstance) { - // Liste + + //Liste server.get("/resource/:resource", async (req, reply) => { if (!req.user?.tenant_id) { return reply.code(400).send({ error: "No tenant selected" }); @@ -499,24 +501,179 @@ export default async function resourceRoutes(server: FastifyInstance) { return sorted; }); + + // Liste Paginated + server.get("/resource/:resource/paginated", async (req, reply) => { + if (!req.user?.tenant_id) { + return reply.code(400).send({error: "No tenant selected"}); + } + + + const {resource} = req.params as { resource: string } + const {queryConfig} = req + + const {pagination, sort, filters, paginationDisabled} = queryConfig + const {select, search, searchColumns,distinctColumns} = req.query as { + select?: string, search?: string, searchColumns?: string, distinctColumns?: string + } + + console.log(queryConfig) + + // --- Supabase-Basisabfrage --- + let baseQuery = server.supabase + .from(resource) + .select(select ? select : "*", {count: "exact"}) // 👈 Zählt Ergebnisse direkt mit + .eq("tenant", req.user.tenant_id) + + // --- 🔍 Serverseitige Suche über angegebene Spalten --- + if (search && search.trim().length > 0) { + const cols = searchColumns + ? searchColumns.split(',').map(c => c.trim()).filter(Boolean) + : [] + + if (cols.length > 0) { + const searchValue = `%${search.trim()}%` + + // JSONB-Unterfelder umwandeln in "->>" Syntax + const formattedCols = cols.map(c => { + if (c.includes('.')) { + const [jsonField, jsonKey] = c.split('.') + return `${jsonField}->>${jsonKey}` + } + return c + }) + + // or() Query dynamisch zusammenbauen + const orConditions = formattedCols + .map(f => `${f}.ilike.${searchValue}`) + .join(',') + + baseQuery = baseQuery.or(orConditions) + } + } + + // --- Filterung (intelligente Typ-Erkennung) --- + for (const [key, val] of Object.entries(queryConfig.filters)) { + if (Array.isArray(val)) { + baseQuery = baseQuery.in(key, val) + //@ts-ignore + } else if (val === true || val === false || val === null) { + baseQuery = baseQuery.is(key, val) + } else { + baseQuery = baseQuery.eq(key, val) + } + } + + // --- Sortierung --- + if (sort.length > 0) { + for (const s of sort) { + baseQuery = baseQuery.order(s.field, {ascending: s.direction === "asc"}) + } + } + + // --- Pagination --- + if (!paginationDisabled && pagination) { + const {offset, limit} = pagination + baseQuery = baseQuery.range(offset, offset + limit - 1) + } + + // --- Abfrage ausführen --- + const {data, error, count} = await baseQuery + + if (error) { + server.log.error(error) + return reply.code(400).send({error: error.message}) + } + + // --- Distinct-Werte ermitteln --- + const distinctValues: Record = {} + if (distinctColumns) { + const cols = distinctColumns.split(",").map(c => c.trim()).filter(Boolean) + + for (const col of cols) { + const isJson = col.includes(".") + const {data: allRows, error: distinctErr} = await server.supabase + .from(resource) + .select(isJson ? "*" : col) + .eq("tenant", req.user.tenant_id) + + if (distinctErr) continue + + const values = (allRows || []) + .map(row => { + if (isJson) { + const [jsonField, jsonKey] = col.split(".") + return row?.[jsonField]?.[jsonKey] ?? null + } + return row?.[col] ?? null + }) + .filter(v => v !== null && v !== undefined && v !== "") + distinctValues[col] = [...new Set(values)].sort() + } + } + + // --- Gesamtanzahl & Seitenberechnung --- + const total = count + const totalPages = + !paginationDisabled && pagination?.limit + ? Math.ceil(total / pagination.limit) + : 1 + + // --- queryConfig erweitern --- + const enrichedConfig = { + ...queryConfig, + total, + totalPages, + distinctValues, + search: search || null + } + + return { + data: data, + queryConfig: enrichedConfig + } + + + /*const { data, error } = await server.supabase + .from(resource) + //@ts-ignore + .select(select || dataTypes[resource].supabaseSelectWithInformation) + .eq("tenant", req.user.tenant_id) + if (error) { + console.log(error) + return reply.code(400).send({ error: error.message }); + } + + const sorted =sortData(data,sort,asc === "true" ? true : false) + + return sorted;*/ + }); + // Detail server.get("/resource/:resource/:id/:with_information?", async (req, reply) => { if (!req.user?.tenant_id) { - return reply.code(400).send({ error: "No tenant selected" }); + return reply.code(400).send({error: "No tenant selected"}); } - const { resource, id, with_information } = req.params as { resource: string; id: string, with_information: boolean }; - const {select } = req.query as { select?: string } + const {resource, id, with_information} = req.params as { + resource: string; + id: string, + with_information: boolean + }; + const {select} = req.query as { select?: string } // @ts-ignore - const { data, error } = await server.supabase.from(resource).select(with_information ? dataTypes[resource].supabaseSelectWithInformation : (select ? select : "*")) + const { + data, + error + } = await server.supabase.from(resource).select(with_information ? dataTypes[resource].supabaseSelectWithInformation : (select ? select : "*")) .eq("id", id) .eq("tenant", req.user.tenant_id) .single(); if (error || !data) { - return reply.code(404).send({ error: "Not found" }); + return reply.code(404).send({error: "Not found"}); } return data; @@ -525,10 +682,10 @@ export default async function resourceRoutes(server: FastifyInstance) { // Create server.post("/resource/:resource", async (req, reply) => { if (!req.user?.tenant_id) { - return reply.code(400).send({ error: "No tenant selected" }); + return reply.code(400).send({error: "No tenant selected"}); } - const { resource } = req.params as { resource: string }; + const {resource} = req.params as { resource: string }; const body = req.body as Record; const dataType = dataTypes[resource]; @@ -538,22 +695,22 @@ export default async function resourceRoutes(server: FastifyInstance) { archived: false, // Standardwert } - if(dataType.numberRangeHolder && !body[dataType.numberRangeHolder]) { - const result = await useNextNumberRangeNumber(server,req.user.tenant_id, resource) + if (dataType.numberRangeHolder && !body[dataType.numberRangeHolder]) { + const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource) createData[dataType.numberRangeHolder] = result.usedNumber } - const { data, error } = await server.supabase + const {data, error} = await server.supabase .from(resource) .insert(createData) .select("*") .single(); if (error) { - return reply.code(400).send({ error: error.message }); + return reply.code(400).send({error: error.message}); } - await insertHistoryItem(server,{ + await insertHistoryItem(server, { entity: resource, entityId: data.id, action: "created", @@ -570,40 +727,39 @@ export default async function resourceRoutes(server: FastifyInstance) { // UPDATE (inkl. Soft-Delete/Archive) server.put("/resource/:resource/:id", async (req, reply) => { console.log("hi") - const { resource, id } = req.params as { resource: string; id: string } + const {resource, id} = req.params as { resource: string; id: string } const body = req.body as Record const tenantId = (req.user as any)?.tenant_id const userId = (req.user as any)?.user_id if (!tenantId || !userId) { - return reply.code(401).send({ error: "Unauthorized" }) + return reply.code(401).send({error: "Unauthorized"}) } // vorherige Version für History laden - const { data: oldItem } = await server.supabase + const {data: oldItem} = await server.supabase .from(resource) .select("*") .eq("id", id) .eq("tenant", tenantId) .single() - const { data:newItem, error } = await server.supabase + const {data: newItem, error} = await server.supabase .from(resource) - .update({ ...body, updated_at: new Date().toISOString(), updated_by: userId }) + .update({...body, updated_at: new Date().toISOString(), updated_by: userId}) .eq("id", id) .eq("tenant", tenantId) .select() .single() - if (error) return reply.code(500).send({ error }) + if (error) return reply.code(500).send({error}) const diffs = diffObjects(oldItem, newItem); - for (const d of diffs) { - await insertHistoryItem(server,{ + await insertHistoryItem(server, { entity: resource, entityId: id, action: d.type,