From 0f3c8c862ff1539c2d9d3e74101a893346756d53 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sat, 6 Dec 2025 22:50:15 +0100 Subject: [PATCH] removed Routes, Introduced Single Route with Cecking --- db/schema/projects.ts | 2 +- db/schema/staff_time_entries.ts | 6 +- src/index.ts | 10 +- src/routes/resources.ts | 829 ---------------------- src/routes/resources/_template.ts | 155 ---- src/routes/resources/contacts.ts | 276 -------- src/routes/resources/contracts.ts | 244 ------- src/routes/resources/customers.ts | 329 --------- src/routes/resources/main.ts | 413 +++++++++++ src/routes/resources/productsServices.ts | 858 +++++++++++++++++++++++ src/routes/resources/vendors.ts | 251 ------- 11 files changed, 1279 insertions(+), 2094 deletions(-) delete mode 100644 src/routes/resources.ts delete mode 100644 src/routes/resources/_template.ts delete mode 100644 src/routes/resources/contacts.ts delete mode 100644 src/routes/resources/contracts.ts delete mode 100644 src/routes/resources/customers.ts create mode 100644 src/routes/resources/main.ts create mode 100644 src/routes/resources/productsServices.ts delete mode 100644 src/routes/resources/vendors.ts diff --git a/db/schema/projects.ts b/db/schema/projects.ts index fc12993..badf583 100644 --- a/db/schema/projects.ts +++ b/db/schema/projects.ts @@ -71,7 +71,7 @@ export const projects = pgTable("projects", { updatedAt: timestamp("updated_at", { withTimezone: true }), updatedBy: uuid("updated_by").references(() => authUsers.id), - activePhase: text("active_phase"), + active_phase: text("active_phase"), }) export type Project = typeof projects.$inferSelect diff --git a/db/schema/staff_time_entries.ts b/db/schema/staff_time_entries.ts index 06534c5..5932fa9 100644 --- a/db/schema/staff_time_entries.ts +++ b/db/schema/staff_time_entries.ts @@ -14,7 +14,7 @@ import { authUsers } from "./auth_users" import { timesStateEnum } from "./enums" import {sql} from "drizzle-orm"; -export const staffTimeEntries = pgTable("staff_time_entries", { +export const stafftimeentries = pgTable("staff_time_entries", { id: uuid("id").primaryKey().defaultRandom(), tenantId: bigint("tenant_id", { mode: "number" }) @@ -64,5 +64,5 @@ export const staffTimeEntries = pgTable("staff_time_entries", { sickReason: text("sick_reason"), }) -export type StaffTimeEntry = typeof staffTimeEntries.$inferSelect -export type NewStaffTimeEntry = typeof staffTimeEntries.$inferInsert +export type StaffTimeEntry = typeof stafftimeentries.$inferSelect +export type NewStaffTimeEntry = typeof stafftimeentries.$inferInsert diff --git a/src/index.ts b/src/index.ts index 265e5ee..5a54508 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,6 @@ import adminRoutes from "./routes/admin"; import corsPlugin from "./plugins/cors"; import queryConfigPlugin from "./plugins/queryconfig"; import dbPlugin from "./plugins/db"; -import resourceRoutes from "./routes/resources"; import resourceRoutesSpecial from "./routes/resourcesSpecial"; import fastifyCookie from "@fastify/cookie"; import historyRoutes from "./routes/history"; @@ -31,9 +30,9 @@ import staffTimeRoutes from "./routes/staff/time"; import staffTimeConnectRoutes from "./routes/staff/timeconnects"; //Resources -import customerRoutes from "./routes/resources/customers"; -import vendorRoutes from "./routes/resources/vendors"; +import productsAndServicesRoutes from "./routes/resources/productsServices"; +import resourceRoutes from "./routes/resources/main"; //M2M import authM2m from "./plugins/auth.m2m"; @@ -104,7 +103,6 @@ async function main() { await subApp.register(meRoutes); await subApp.register(tenantRoutes); await subApp.register(adminRoutes); - await subApp.register(resourceRoutes); await subApp.register(resourceRoutesSpecial); await subApp.register(historyRoutes); await subApp.register(fileRoutes); @@ -120,8 +118,8 @@ async function main() { await subApp.register(staffTimeConnectRoutes); - await subApp.register(customerRoutes); - await subApp.register(vendorRoutes); + await subApp.register(productsAndServicesRoutes); + await subApp.register(resourceRoutes); },{prefix: "/api"}) diff --git a/src/routes/resources.ts b/src/routes/resources.ts deleted file mode 100644 index b1a3358..0000000 --- a/src/routes/resources.ts +++ /dev/null @@ -1,829 +0,0 @@ -import {FastifyInstance} from "fastify"; -import {insertHistoryItem} from "../utils/history" -import {diffObjects} from "../utils/diff"; -import {sortData} from "../utils/sort"; -import {useNextNumberRangeNumber} from "../utils/functions"; -import {compareValues, getNestedValue} from "../utils/helpers"; - - -const dataTypes: any[] = { - // @ts-ignore - tasks: { - isArchivable: true, - label: "Aufgaben", - labelSingle: "Aufgabe", - isStandardEntity: true, - redirect: true, - historyItemHolder: "task", - supabaseSelectWithInformation: "*, plant(*), project(*), customer(*)", - inputColumns: [ - "Allgemeines", - "Zuweisungen" - ], - showTabs: [{label: 'Informationen'}] - }, - customers: { - isArchivable: true, - label: "Kunden", - labelSingle: "Kunde", - isStandardEntity: true, - redirect: true, - numberRangeHolder: "customerNumber", - historyItemHolder: "customer", - supabaseSortColumn: "customerNumber", - supabaseSelectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*)", - inputColumns: [ - "Allgemeines", - "Kontaktdaten" - ], - 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, - historyItemHolder: "contact", - supabaseSelectWithInformation: "*, customer(*), vendor(*)", - showTabs: [ - { - label: 'Informationen', - } - ] - }, - contracts: { - isArchivable: true, - label: "Verträge", - labelSingle: "Vertrag", - isStandardEntity: true, - numberRangeHolder: "contractNumber", - redirect: true, - inputColumns: [ - "Allgemeines", - "Abrechnung" - ], - supabaseSelectWithInformation: "*, customer(*), files(*)", - showTabs: [{label: 'Informationen'}, {label: 'Dateien'}] - }, - absencerequests: { - isArchivable: true, - label: "Abwesenheiten", - labelSingle: "Abwesenheit", - isStandardEntity: true, - supabaseSortColumn: "startDate", - supabaseSortAscending: false, - supabaseSelectWithInformation: "*", - historyItemHolder: "absencerequest", - redirect: true, - showTabs: [{label: 'Informationen'}] - }, - plants: { - isArchivable: true, - label: "Objekte", - labelSingle: "Objekt", - isStandardEntity: true, - redirect: true, - historyItemHolder: "plant", - supabaseSelectWithInformation: "*, customer(id,name)", - showTabs: [ - { - label: "Informationen" - }, { - label: "Projekte" - }, { - label: "Aufgaben" - }, { - label: "Dateien" - }] - }, - products: { - isArchivable: true, - label: "Artikel", - labelSingle: "Artikel", - isStandardEntity: true, - redirect: true, - supabaseSelectWithInformation: "*, unit(name)", - historyItemHolder: "product", - showTabs: [ - { - label: "Informationen" - } - ] - }, - projects: { - isArchivable: true, - label: "Projekte", - labelSingle: "Projekt", - isStandardEntity: 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))", - supabaseSortColumn: "projectNumber", - showTabs: [ - { - key: "information", - label: "Informationen" - }, - { - key: "phases", - label: "Phasen" - }, { - key: "tasks", - label: "Aufgaben" - }, { - key: "files", - label: "Dateien" - }, { - label: "Zeiten" - }, { - label: "Ausgangsbelege" - }, { - label: "Termine" - }/*,{ - key: "timetracking", - label: "Zeiterfassung" - },{ - key: "events", - label: "Termine" - },{ - key: "material", - label: "Material" - }*/] - }, - vehicles: { - isArchivable: true, - label: "Fahrzeuge", - labelSingle: "Fahrzeug", - isStandardEntity: true, - redirect: true, - historyItemHolder: "vehicle", - supabaseSelectWithInformation: "*, checks(*), files(*)", - showTabs: [ - { - label: 'Informationen', - }, { - label: 'Dateien', - }, { - label: 'Überprüfungen', - } - ] - }, - vendors: { - isArchivable: true, - label: "Lieferanten", - labelSingle: "Lieferant", - isStandardEntity: true, - redirect: true, - numberRangeHolder: "vendorNumber", - historyItemHolder: "vendor", - supabaseSortColumn: "vendorNumber", - supabaseSelectWithInformation: "*, contacts(*)", - showTabs: [ - { - label: 'Informationen', - }, { - label: 'Ansprechpartner', - }, { - label: 'Dateien', - } - ] - }, - messages: { - label: "Nachrichten", - labelSingle: "Nachricht" - }, - spaces: { - isArchivable: true, - label: "Lagerplätze", - labelSingle: "Lagerplatz", - isStandardEntity: true, - supabaseSelectWithInformation: "*, files(*)", - supabaseSortColumn: "spaceNumber", - redirect: true, - numberRangeHolder: "spaceNumber", - historyItemHolder: "space", - inputColumns: [ - "Allgemeines", - "Ort" - ], - showTabs: [ - { - label: 'Informationen', - }, { - label: 'Dateien', - }, {label: 'Inventarartikel'} - ] - }, - users: { - label: "Benutzer", - labelSingle: "Benutzer" - }, - createddocuments: { - isArchivable: true, - label: "Dokumente", - 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", - redirect: true - }, - inventoryitems: { - isArchivable: true, - label: "Inventarartikel", - labelSingle: "Inventarartikel", - isStandardEntity: true, - supabaseSelectWithInformation: "*, files(*), vendor(id,name), currentSpace(id,name)", - redirect: true, - numberRangeHolder: "articleNumber", - historyItemHolder: "inventoryitem", - inputColumns: [ - "Allgemeines", - "Anschaffung" - ], - showTabs: [ - { - label: 'Informationen', - }, { - label: 'Dateien', - } - ] - }, - inventoryitemgroups: { - isArchivable: true, - label: "Inventarartikelgruppen", - labelSingle: "Inventarartikelgruppe", - isStandardEntity: true, - historyItemHolder: "inventoryitemgroup", - supabaseSelectWithInformation: "*", - redirect: true, - showTabs: [ - { - label: 'Informationen', - } - ] - }, - documentboxes: { - isArchivable: true, - label: "Dokumentenboxen", - labelSingle: "Dokumentenbox", - isStandardEntity: true, - supabaseSelectWithInformation: "*, space(*), files(*)", - redirect: true, - numberRangeHolder: "key", - historyItemHolder: "documentbox", - inputColumns: [ - "Allgemeines", - ], - showTabs: [ - { - label: 'Informationen', - }, { - label: 'Dateien', - } - ] - }, - services: { - isArchivable: true, - label: "Leistungen", - labelSingle: "Leistung", - isStandardEntity: true, - redirect: true, - supabaseSelectWithInformation: "*, unit(*)", - historyItemHolder: "service", - showTabs: [ - { - label: 'Informationen', - } - ] - }, - hourrates: { - isArchivable: true, - label: "Stundensätze", - labelSingle: "Stundensatz", - isStandardEntity: true, - redirect: true, - supabaseSelectWithInformation: "*", - historyItemHolder: "hourrate", - showTabs: [ - { - label: 'Informationen', - } - ] - }, - events: { - isArchivable: true, - label: "Termine", - labelSingle: "Termin", - isStandardEntity: true, - historyItemHolder: "event", - supabaseSelectWithInformation: "*, project(id,name), customer(*)", - redirect: true, - showTabs: [ - { - label: 'Informationen', - } - ] - }, - profiles: { - label: "Mitarbeiter", - labelSingle: "Mitarbeiter", - redirect: true, - historyItemHolder: "profile" - }, - workingtimes: { - isArchivable: true, - label: "Anwesenheiten", - labelSingle: "Anwesenheit", - redirect: true, - redirectToList: true - }, - texttemplates: { - isArchivable: true, - label: "Textvorlagen", - labelSingle: "Textvorlage" - }, - bankstatements: { - isArchivable: true, - label: "Kontobewegungen", - labelSingle: "Kontobewegung", - historyItemHolder: "bankStatement", - }, - statementallocations: { - label: "Bankzuweisungen", - labelSingle: "Bankzuweisung" - }, - productcategories: { - isArchivable: true, - label: "Artikelkategorien", - labelSingle: "Artikelkategorie", - isStandardEntity: true, - redirect: true, - supabaseSelectWithInformation: "*", - showTabs: [ - { - label: 'Informationen', - } - ] - }, - servicecategories: { - isArchivable: true, - label: "Leistungskategorien", - labelSingle: "Leistungskategorie", - isStandardEntity: true, - redirect: true, - supabaseSelectWithInformation: "*", - showTabs: [ - { - label: 'Informationen', - } - ] - }, - trackingtrips: { - label: "Fahrten", - labelSingle: "Fahrt", - redirect: true, - historyItemHolder: "trackingtrip", - }, - projecttypes: { - isArchivable: true, - label: "Projekttypen", - labelSingle: "Projekttyp", - redirect: true, - historyItemHolder: "projecttype" - }, - checks: { - isArchivable: true, - label: "Überprüfungen", - labelSingle: "Überprüfung", - isStandardEntity: true, - supabaseSelectWithInformation: "*, vehicle(id,licensePlate), profile(id, fullName), inventoryitem(name), files(*)", - redirect: true, - historyItemHolder: "check", - showTabs: [ - { - label: 'Informationen', - }, {label: 'Dateien'}, {label: 'Ausführungen'}] - }, - roles: { - label: "Rollen", - labelSingle: "Rolle", - redirect: true, - historyItemHolder: "role", - filters: [], - templateColumns: [ - { - key: "name", - label: "Name" - }, { - key: "description", - label: "Beschreibung" - } - ] - }, - costcentres: { - isArchivable: true, - label: "Kostenstellen", - labelSingle: "Kostenstelle", - isStandardEntity: true, - redirect: true, - numberRangeHolder: "number", - historyItemHolder: "costcentre", - supabaseSortColumn: "number", - supabaseSelectWithInformation: "*, project(*), vehicle(*), inventoryitem(*)", - showTabs: [{label: 'Informationen'}, {label: 'Auswertung Kostenstelle'}] - }, - ownaccounts: { - isArchivable: true, - label: "zusätzliche Buchungskonten", - labelSingle: "zusätzliches Buchungskonto", - isStandardEntity: true, - redirect: true, - historyItemHolder: "ownaccount", - supabaseSortColumn: "number", - supabaseSelectWithInformation: "*, statementallocations(*, bs_id(*))", - showTabs: [{label: 'Informationen'}, {label: 'Buchungen'}] - }, - tickets: { - isArchivable: true, - label: "Tickets", - labelSingle: "Ticket", - - }, - ticketmessages: { - isArchivable: true, - label: "Nachrichten", - labelSingle: "Nachricht", - - }, -} - -export default async function resourceRoutes(server: FastifyInstance) { - - //Liste - server.get("/resource/:resource", 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 {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(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; - }); - - - // Helper Funktionen - - -// 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(req.query); - console.log(select); - - // --- 🔍 Suche (im Backend mit Joins) --- - if (search && search.trim().length > 0) { - // 1. Alle Daten mit Joins holen (OHNE Pagination, aber mit Filtern) - let searchQuery = server.supabase - .from(resource) - .select(select || dataTypes[resource].supabaseSelectWithInformation) - .eq("tenant", req.user.tenant_id); - - // --- Filterung anwenden --- - for (const [key, val] of Object.entries(filters || {})) { - if (Array.isArray(val)) { - searchQuery = searchQuery.in(key, val); - } else { // @ts-ignore - if (val === true || val === false || val === null) { - searchQuery = searchQuery.is(key, val); - } else { - searchQuery = searchQuery.eq(key, val); - } - } - } - - const { data: allData, error: searchError } = await searchQuery; - - if (searchError) { - server.log.error(searchError); - return reply.code(400).send({ error: searchError.message }); - } - - // 2. Im Backend nach Suchbegriff filtern - const searchTerm = search.trim().toLowerCase(); - const searchCols = searchColumns - ? searchColumns.split(",").map(c => c.trim()).filter(Boolean) - : dataTypes[resource].searchableColumns || []; - - const filteredData = (allData || []).filter(row => { - /*if (searchCols.length === 0) { - // Fallback: Durchsuche alle String-Felder der Hauptebene - return Object.values(row).some(val => - JSON.stringify(val).toString().toLowerCase().includes(searchTerm) - ); - } - - return searchCols.some(col => { - const value = getNestedValue(row, col); - return JSON.stringify(value).toLowerCase().includes(searchTerm); - });*/ - - return JSON.stringify(row).toLowerCase().includes(searchTerm); - - }); - - // 3. Im Backend sortieren - let sortedData = [...filteredData]; - if (sort?.length > 0) { - sortedData.sort((a, b) => { - for (const s of sort) { - const aVal = getNestedValue(a, s.field); - const bVal = getNestedValue(b, s.field); - const comparison = compareValues(aVal, bVal); - if (comparison !== 0) { - return s.direction === "asc" ? comparison : -comparison; - } - } - return 0; - }); - } - - // 4. Im Backend paginieren - const total = sortedData.length; - const paginatedData = !paginationDisabled && pagination - ? sortedData.slice(pagination.offset, pagination.offset + pagination.limit) - : sortedData; - - // 5. Distinct Values berechnen - const distinctValues: Record = {}; - if (distinctColumns) { - const cols = distinctColumns.split(",").map(c => c.trim()).filter(Boolean); - - for (const col of cols) { - // Distinct values aus den gefilterten Daten - const values = filteredData - .map(row => getNestedValue(row, col)) - .filter(v => v !== null && v !== undefined && v !== ""); - distinctValues[col] = [...new Set(values)].sort(); - } - } - - const totalPages = !paginationDisabled && pagination?.limit - ? Math.ceil(total / pagination.limit) - : 1; - - const enrichedConfig = { - ...queryConfig, - total, - totalPages, - distinctValues, - search: search || null, - }; - - return { data: paginatedData, queryConfig: enrichedConfig }; - } - - // --- Standardabfrage (ohne Suche) --- - let baseQuery = server.supabase - .from(resource) - .select(select || dataTypes[resource].supabaseSelectWithInformation, { count: "exact" }) - .eq("tenant", req.user.tenant_id); - - // --- Filterung --- - for (const [key, val] of Object.entries(filters || {})) { - if (Array.isArray(val)) { - baseQuery = baseQuery.in(key, val); - } else { // @ts-ignore - 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); - } - - const { data, error, count } = await baseQuery; - if (error) { - server.log.error(error); - return reply.code(400).send({ error: error.message }); - } - - // --- Distinct-Werte (auch ohne Suche) --- - const distinctValues: Record = {}; - if (distinctColumns) { - const cols = distinctColumns.split(",").map(c => c.trim()).filter(Boolean); - - for (const col of cols) { - const { data: allRows, error: distinctErr } = await server.supabase - .from(resource) - .select(col) - .eq("tenant", req.user.tenant_id); - - if (distinctErr) continue; - - const values = (allRows || []) - .map((row) => row?.[col] ?? null) - .filter((v) => v !== null && v !== undefined && v !== ""); - distinctValues[col] = [...new Set(values)].sort(); - } - } - - const total = count || 0; - const totalPages = !paginationDisabled && pagination?.limit - ? Math.ceil(total / pagination.limit) - : 1; - - const enrichedConfig = { - ...queryConfig, - total, - totalPages, - distinctValues, - search: search || null, - }; - - return { data, queryConfig: enrichedConfig }; - }); - - - // 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"}); - } - - 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 : "*")) - .eq("id", id) - .eq("tenant", req.user.tenant_id) - .single(); - - if (error || !data) { - return reply.code(404).send({error: "Not found"}); - } - - return data; - }); - - // Create - server.post("/resource/:resource", 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 body = req.body as Record; - - const dataType = dataTypes[resource]; - let createData = { - ...body, - tenant: req.user.tenant_id, - archived: false, // Standardwert - } - - 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 - .from(resource) - .insert(createData) - .select("*") - .single(); - - if (error) { - return reply.code(400).send({error: error.message}); - } - - await insertHistoryItem(server, { - entity: resource, - entityId: data.id, - action: "created", - created_by: req.user.user_id, - tenant_id: req.user.tenant_id, - oldVal: null, - newVal: data, - text: `${dataType.labelSingle} erstellt`, - }); - - return data; - }); - - // 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 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"}) - } - - // vorherige Version für History laden - const {data: oldItem} = await server.supabase - .from(resource) - .select("*") - .eq("id", id) - .eq("tenant", tenantId) - .single() - - const {data: newItem, error} = await server.supabase - .from(resource) - .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}) - - const diffs = diffObjects(oldItem, newItem); - - - for (const d of diffs) { - await insertHistoryItem(server, { - entity: resource, - entityId: id, - action: d.type, - created_by: userId, - tenant_id: tenantId, - oldVal: d.oldValue ? String(d.oldValue) : null, - newVal: d.newValue ? String(d.newValue) : null, - text: `Feld "${d.label}" ${d.typeLabel}: ${d.oldValue ?? ""} → ${d.newValue ?? ""}`, - }); - } - - return newItem - }) -} \ No newline at end of file diff --git a/src/routes/resources/_template.ts b/src/routes/resources/_template.ts deleted file mode 100644 index 03386e5..0000000 --- a/src/routes/resources/_template.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { FastifyInstance } from "fastify" -import { asc, desc, eq, ilike, and } from "drizzle-orm" - -// Beispiel-Import — wird in der echten Datei ersetzt -import { exampleTable } from "../../../db/schema/exampleTable" - -export default async function exampleRoutes(server: FastifyInstance) { - - // ------------------------------------------- - // LIST (ohne Pagination) - // ------------------------------------------- - server.get("/resource/example", async (req, reply) => { - const tenantId = req.user?.tenant_id - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const { search, sort, asc: ascQuery } = req.query as { - search?: string - sort?: string - asc?: string - } - - let query = server.db - .select() - .from(exampleTable) - .where(eq(exampleTable.tenant, tenantId)) - - // 🔍 OPTIONAL: einfache Suche (LIKE) - if (search) { - query = server.db - .select() - .from(exampleTable) - .where( - and( - eq(exampleTable.tenant, tenantId), - ilike(exampleTable.name as any, `%${search}%`) - ) - ) - } - - // 🔄 Sortierung - if (sort) { - query = query.orderBy( - ascQuery === "true" - ? asc((exampleTable as any)[sort]) - : desc((exampleTable as any)[sort]) - ) - } - - const results = await query - return results - }) - - // ------------------------------------------- - // PAGINATED LIST - // ------------------------------------------- - server.get("/resource/example/paginated", async (req, reply) => { - const tenantId = req.user?.tenant_id - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const { - offset = "0", - limit = "25", - search, - sort, - asc: ascQuery, - } = req.query as { - offset?: string - limit?: string - search?: string - sort?: string - asc?: string - } - - const offsetNum = parseInt(offset) - const limitNum = parseInt(limit) - - // --- Basis WHERE - let whereClause: any = eq(exampleTable.tenant, tenantId) - - // --- Suche - if (search) { - whereClause = and( - eq(exampleTable.tenant, tenantId), - ilike(exampleTable.name as any, `%${search}%`) - ) - } - - // ------------------------- - // 1. COUNT Query (total rows) - // ------------------------- - const totalRowsResult = await server.db - .select({ count: server.db.fn.count(exampleTable.id) }) - .from(exampleTable) - .where(whereClause) - - const total = Number(totalRowsResult[0].count) - - // ------------------------- - // 2. DATA Query - // ------------------------- - let dataQuery = server.db - .select() - .from(exampleTable) - .where(whereClause) - .offset(offsetNum) - .limit(limitNum) - - if (sort) { - dataQuery = dataQuery.orderBy( - ascQuery === "true" - ? asc((exampleTable as any)[sort]) - : desc((exampleTable as any)[sort]) - ) - } - - const rows = await dataQuery - - return { - data: rows, - pagination: { - total, - offset: offsetNum, - limit: limitNum, - totalPages: Math.ceil(total / limitNum), - }, - } - }) - - // ------------------------------------------- - // DETAIL ROUTE - // ------------------------------------------- - server.get("/resource/example/:id", async (req, reply) => { - const { id } = req.params as { id: string } - const tenantId = req.user?.tenant_id - - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const result = await server.db - .select() - .from(exampleTable) - .where( - and( - eq(exampleTable.id, id), - eq(exampleTable.tenant, tenantId) - ) - ) - .limit(1) - - if (!result.length) { - return reply.code(404).send({ error: "Not found" }) - } - - return result[0] - }) -} diff --git a/src/routes/resources/contacts.ts b/src/routes/resources/contacts.ts deleted file mode 100644 index ae5a675..0000000 --- a/src/routes/resources/contacts.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { FastifyInstance } from "fastify" -import { - eq, - ilike, - asc, - desc, - and, - count, - inArray, - or -} from "drizzle-orm" - -import { - contacts, - customers, - vendors -} from "../../../db/schema" - - -// ------------------------------------------------------------- -// 🔍 Helper für SQL-Suche über mehrere Spalten -// ------------------------------------------------------------- -function buildSearchCondition(table: any, columns: string[], search: string) { - if (!search || !columns.length) return null - - const term = `%${search.toLowerCase()}%` - - const conditions = columns - .map(c => table[c]) - .filter(Boolean) - .map(col => ilike(col, term)) - - if (conditions.length === 0) return null - - // @ts-ignore - return or(...conditions) -} - - - -export default async function contactsRoutes(server: FastifyInstance) { - - - // ------------------------------------------------------------- - // LIST - // ------------------------------------------------------------- - server.get("/resource/contacts", async (req, reply) => { - const tenantId = req.user?.tenant_id - if (!tenantId) - return reply.code(400).send({ error: "No tenant selected" }) - - const { search, sort, asc: ascQuery } = req.query as { - search?: string - sort?: string - asc?: string - } - - // Grundfilter - let whereCond: any = eq(contacts.tenant, tenantId) - - // 🔍 Suche - if (search) { - const searchCond = buildSearchCondition( - contacts, - ["firstName", "lastName", "email", "phone", "notes"], - search - ) - if (searchCond) whereCond = and(whereCond, searchCond) - } - - // Query - let q = server.db.select().from(contacts).where(whereCond) - - // Sortierung - if (sort) { - const field = (contacts as any)[sort] - if (field) { - //@ts-ignore - q = q.orderBy( - ascQuery === "true" ? asc(field) : desc(field) - ) - } - } - - return await q - }) - - - - // ------------------------------------------------------------- - // PAGINATED - // ------------------------------------------------------------- - server.get("/resource/contacts/paginated", async (req, reply) => { - try { - const tenantId = req.user?.tenant_id - if (!tenantId) - return reply.code(400).send({ error: "No tenant selected" }) - - const queryConfig = req.queryConfig - const { - pagination, - sort, - filters, - paginationDisabled - } = queryConfig - - const { search, distinctColumns } = req.query as { - search?: string - distinctColumns?: string - } - - // ----------------------------------- - // WHERE CONDITIONS - // ----------------------------------- - let whereCond: any = eq(contacts.tenant, tenantId) - - // Filter - if (filters) { - for (const [key, val] of Object.entries(filters)) { - const col = (contacts as any)[key] - if (!col) continue - - if (Array.isArray(val)) { - whereCond = and(whereCond, inArray(col, val)) - } else { - whereCond = and(whereCond, eq(col, val as any)) - } - } - } - - // 🔍 Suche - if (search && search.trim().length > 0) { - const searchCond = buildSearchCondition( - contacts, - ["firstName", "lastName", "email", "phone", "notes"], - search - ) - if (searchCond) whereCond = and(whereCond, searchCond) - } - - // ----------------------------------- - // COUNT - // ----------------------------------- - const totalRes = await server.db - .select({ value: count(contacts.id) }) - .from(contacts) - .where(whereCond) - - const total = Number(totalRes[0]?.value ?? 0) - - // ----------------------------------- - // DISTINCT VALUES - // ----------------------------------- - const distinctValues: Record = {} - - if (distinctColumns) { - for (const colName of distinctColumns.split(",").map(v => v.trim())) { - const col = (contacts as any)[colName] - if (!col) continue - - const rows = await server.db - .select({ v: col }) - .from(contacts) - .where(eq(contacts.tenant, tenantId)) - - distinctValues[colName] = - [...new Set(rows.map(r => r.v).filter(v => v !== null && v !== ""))] - .sort() - } - } - - // ----------------------------------- - // PAGINATION - // ----------------------------------- - let offset = pagination?.offset ?? 0 - let limit = pagination?.limit ?? 999999 - - // ----------------------------------- - // ORDER - // ----------------------------------- - let orderField = null - let orderDirection: "asc" | "desc" = "asc" - - if (sort?.length > 0) { - const s = sort[0] - const col = (contacts as any)[s.field] - if (col) { - orderField = col - orderDirection = s.direction === "asc" ? "asc" : "desc" - } - } - - // ----------------------------------- - // DATA QUERY - // ----------------------------------- - let dataQuery = server.db - .select() - .from(contacts) - .where(whereCond) - .offset(offset) - .limit(limit) - - if (orderField) { - //@ts-ignore - dataQuery = - orderDirection === "asc" - ? dataQuery.orderBy(asc(orderField)) - : dataQuery.orderBy(desc(orderField)) - } - - const data = await dataQuery - - return { - data, - queryConfig: { - ...queryConfig, - total, - totalPages: pagination?.limit - ? Math.ceil(total / pagination.limit) - : 1, - distinctValues, - search: search || null - } - } - - } catch (err) { - console.error(err) - return reply.code(500).send({ error: "Internal Server Error" }) - } - }) - - - - // ------------------------------------------------------------- - // DETAIL (customer + vendor) - // ------------------------------------------------------------- - server.get("/resource/contacts/:id", async (req, reply) => { - const { id } = req.params as { id: string } - const tenantId = req.user?.tenant_id - - if (!tenantId) - return reply.code(400).send({ error: "No tenant selected" }) - - const rows = await server.db - .select() - .from(contacts) - .where( - and( - eq(contacts.id, Number(id)), - eq(contacts.tenant, tenantId) - ) - ) - .limit(1) - - if (!rows.length) - return reply.code(404).send({ error: "Not found" }) - - const contact = rows[0] - - const [customerRecord, vendorRecord] = await Promise.all([ - contact.customer - ? server.db.select().from(customers).where(eq(customers.id, contact.customer)) - : [], - contact.vendor - ? server.db.select().from(vendors).where(eq(vendors.id, contact.vendor)) - : [], - ]) - - return { - ...contact, - customer: customerRecord[0] ?? null, - vendor: vendorRecord[0] ?? null, - } - }) - -} diff --git a/src/routes/resources/contracts.ts b/src/routes/resources/contracts.ts deleted file mode 100644 index c0e8d6c..0000000 --- a/src/routes/resources/contracts.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { FastifyInstance } from "fastify" -import { - eq, - ilike, - asc, - desc, - and, - count, - inArray, - or -} from "drizzle-orm" - -import { - contracts, - customers, - files -} from "../../../db/schema" - -// ------------------------------------------------------------- -// Helper: SQL‐LIKE Suche über mehrere Felder -// ------------------------------------------------------------- -function buildSearchCondition(table: any, columns: string[], search?: string) { - if (!search) return null - - const term = `%${search.toLowerCase()}%` - - const conditions = columns - .map(col => table[col]) - .filter(Boolean) - .map(col => ilike(col, term)) - - if (conditions.length === 0) return null - - // @ts-ignore - return or(...conditions) -} - -export default async function contractsRoutes(server: FastifyInstance) { - - // ------------------------------------------------------------- - // LIST - // ------------------------------------------------------------- - server.get("/resource/contracts", async (req, reply) => { - const tenantId = req.user?.tenant_id - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const { search, sort, asc: ascQuery } = req.query as { - search?: string - sort?: string - asc?: string - } - - let whereCond: any = eq(contracts.tenant, tenantId) - - // SQL SEARCH - const searchCond = buildSearchCondition( - contracts, - ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"], - search - ) - if (searchCond) whereCond = and(whereCond, searchCond) - - // Query - let q = server.db - .select() - .from(contracts) - .where(whereCond) - - // SORT - if (sort) { - const field = (contracts as any)[sort] - if (field) { - //@ts-ignore - q = q.orderBy( - ascQuery === "true" ? asc(field) : desc(field) - ) - } - } - - return await q - }) - - - // ------------------------------------------------------------- - // PAGINATED - // ------------------------------------------------------------- - server.get("/resource/contracts/paginated", async (req, reply) => { - try { - const tenantId = req.user?.tenant_id - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const queryConfig = req.queryConfig - const { pagination, sort, filters, paginationDisabled } = queryConfig - - const { search, distinctColumns } = req.query as { - search?: string - distinctColumns?: string - } - - // ----------------------------------- - // WHERE - // ----------------------------------- - let whereCond: any = eq(contracts.tenant, tenantId) - - if (filters) { - for (const [key, val] of Object.entries(filters)) { - const col = (contracts as any)[key] - if (!col) continue - - if (Array.isArray(val)) { - whereCond = and(whereCond, inArray(col, val)) - } else { - whereCond = and(whereCond, eq(col, val as any)) - } - } - } - - // SQL SEARCH - const searchCond = buildSearchCondition( - contracts, - ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"], - search - ) - if (searchCond) whereCond = and(whereCond, searchCond) - - // ----------------------------------- - // COUNT - // ----------------------------------- - const totalRes = await server.db - .select({ value: count(contracts.id) }) - .from(contracts) - .where(whereCond) - - const total = Number(totalRes[0]?.value ?? 0) - - // ----------------------------------- - // DISTINCT - // ----------------------------------- - const distinctValues: Record = {} - - if (distinctColumns) { - for (const colName of distinctColumns.split(",")) { - const col = (contracts as any)[colName.trim()] - if (!col) continue - - const rows = await server.db - .select({ v: col }) - .from(contracts) - .where(eq(contracts.tenant, tenantId)) - - distinctValues[colName] = - [...new Set(rows.map(r => r.v).filter(v => v != null && v !== ""))] - .sort() - } - } - - // ----------------------------------- - // PAGINATION - // ----------------------------------- - let offset = pagination?.offset ?? 0 - let limit = pagination?.limit ?? 999999 - - // ----------------------------------- - // SORT - // ----------------------------------- - let orderField = null - let orderDir: "asc" | "desc" = "asc" - - if (sort?.length > 0) { - const s = sort[0] - const col = (contracts as any)[s.field] - if (col) { - orderField = col - orderDir = s.direction === "asc" ? "asc" : "desc" - } - } - - // ----------------------------------- - // QUERY DATA - // ----------------------------------- - let q = server.db - .select() - .from(contracts) - .where(whereCond) - .offset(offset) - .limit(limit) - - if (orderField) { - //@ts-ignore - q = orderDir === "asc" ? q.orderBy(asc(orderField)) : q.orderBy(desc(orderField)) - } - - const data = await q - - return { - data, - queryConfig: { - ...queryConfig, - total, - totalPages: pagination?.limit ? Math.ceil(total / pagination.limit) : 1, - distinctValues, - search: search || null, - } - } - } catch (e) { - console.error(e) - return reply.code(500).send({ error: "Internal Server Error" }) - } - }) - - - // ------------------------------------------------------------- - // DETAIL (+ JOINS) - // ------------------------------------------------------------- - server.get("/resource/contracts/:id", async (req, reply) => { - const { id } = req.params as { id: string } - const tenantId = req.user?.tenant_id - - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const rows = await server.db - .select() - .from(contracts) - .where(and(eq(contracts.id, Number(id)), eq(contracts.tenant, tenantId))) - .limit(1) - - if (!rows.length) return reply.code(404).send({ error: "Not found" }) - - const contract = rows[0] - - const [customerRecord, fileList] = await Promise.all([ - contract.customer - ? server.db.select().from(customers).where(eq(customers.id, contract.customer)) - : [], - server.db.select().from(files).where(eq(files.contract, Number(id))), - ]) - - return { - ...contract, - customer: customerRecord[0] ?? null, - files: fileList, - } - }) -} diff --git a/src/routes/resources/customers.ts b/src/routes/resources/customers.ts deleted file mode 100644 index 773b94f..0000000 --- a/src/routes/resources/customers.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { FastifyInstance } from "fastify" -import { - eq, - ilike, - asc, - desc, - and, - count, - inArray, - or, -} from "drizzle-orm" - -import { - customers, - projects, - plants, - contracts, - contacts, - createddocuments, - statementallocations, - files, - events, -} from "../../../db/schema" - - -// ------------------------------------------------------------- -// 🔍 Helper für SQL-Suche über mehrere Spalten -// ------------------------------------------------------------- -function buildSearchCondition(table: any, columns: string[], search: string) { - if (!search || !columns.length) return null - - const term = `%${search.toLowerCase()}%` - - const conditions = columns - .map((colName) => table[colName]) - .filter(Boolean) - .map((col) => ilike(col, term)) - - if (conditions.length === 0) return null - - // @ts-ignore - return or(...conditions) -} - - - -export default async function customerRoutes(server: FastifyInstance) { - - - // ------------------------------------------------------------- - // LIST - // ------------------------------------------------------------- - server.get("/resource/customers", async (req, reply) => { - const tenantId = req.user?.tenant_id - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const { search, sort, asc: ascQuery } = req.query as { - search?: string - sort?: string - asc?: string - } - - // Basisquery - let whereCond: any = eq(customers.tenant, tenantId) - - // 🔍 SQL-Suche - if (search) { - const searchCond = buildSearchCondition( - customers, - ["name", "customerNumber", "firstname", "lastname", "notes"], - search - ) - - if (searchCond) { - whereCond = and(whereCond, searchCond) - } - } - - let baseQuery = server.db - .select() - .from(customers) - .where(whereCond) - - // Sortierung - if (sort) { - const field = (customers as any)[sort] - if (field) { - //@ts-ignore - baseQuery = baseQuery.orderBy( - ascQuery === "true" ? asc(field) : desc(field) - ) - } - } - - return await baseQuery - }) - - - - // ------------------------------------------------------------- - // PAGINATED - // ------------------------------------------------------------- - server.get("/resource/customers/paginated", async (req, reply) => { - try { - const tenantId = req.user?.tenant_id - if (!tenantId) { - return reply.code(400).send({ error: "No tenant selected" }) - } - - const queryConfig = req.queryConfig - const { - pagination, - sort, - filters, - paginationDisabled - } = queryConfig - - const { - search, - distinctColumns - } = req.query as { - search?: string - distinctColumns?: string - } - - // ---------------------------- - // WHERE CONDITIONS (Basis) - // ---------------------------- - let whereCond: any = eq(customers.tenant, tenantId) - - // Filters - if (filters) { - for (const [key, val] of Object.entries(filters)) { - const col = (customers as any)[key] - if (!col) continue - - if (Array.isArray(val)) { - whereCond = and(whereCond, inArray(col, val)) - } else { - whereCond = and(whereCond, eq(col, val as any)) - } - } - } - - // ---------------------------- - // 🔍 SEARCH - // ---------------------------- - if (search && search.trim().length > 0) { - const searchCond = buildSearchCondition( - customers, - ["name", "customerNumber", "firstname", "lastname", "notes"], - search.trim() - ) - - if (searchCond) { - whereCond = and(whereCond, searchCond) - } - } - - // ---------------------------- - // COUNT - // ---------------------------- - const totalRes = await server.db - .select({ value: count(customers.id) }) - .from(customers) - .where(whereCond) - - const total = Number(totalRes[0]?.value ?? 0) - - // ---------------------------- - // DISTINCT VALUES - // ---------------------------- - const distinctValues: Record = {} - - if (distinctColumns) { - for (const colName of distinctColumns.split(",").map(v => v.trim())) { - const col = (customers as any)[colName] - if (!col) continue - - const rows = await server.db - .select({ v: col }) - .from(customers) - .where(eq(customers.tenant, tenantId)) - - distinctValues[colName] = - [...new Set(rows.map(r => r.v).filter(v => v != null && v !== ""))] - .sort() - } - } - - // ---------------------------- - // PAGINATION - // ---------------------------- - let offset = 0 - let limit = 999999 - - if (!paginationDisabled && pagination) { - offset = pagination.offset - limit = pagination.limit - } - - // ---------------------------- - // ORDER BY - // ---------------------------- - let orderField = null - let orderDirection: "asc" | "desc" = "asc" - - if (sort?.length > 0) { - const s = sort[0] - const col = (customers as any)[s.field] - if (col) { - orderField = col - orderDirection = s.direction === "asc" ? "asc" : "desc" - } - } - - // ---------------------------- - // QUERY DATA - // ---------------------------- - let dataQuery = server.db - .select() - .from(customers) - .where(whereCond) - .offset(offset) - .limit(limit) - - if (orderField) { - //@ts-ignore - dataQuery = - orderDirection === "asc" - ? dataQuery.orderBy(asc(orderField)) - : dataQuery.orderBy(desc(orderField)) - } - - const data = await dataQuery - - // ---------------------------- - // CONFIG RESPONSE - // ---------------------------- - const totalPages = pagination?.limit - ? Math.ceil(total / pagination.limit) - : 1 - - const enrichedConfig = { - ...queryConfig, - total, - totalPages, - distinctValues, - search: search || null, - } - - return { - data, - queryConfig: enrichedConfig, - } - } - catch (e) { - console.log(e) - } - }) - - - - // ------------------------------------------------------------- - // DETAIL (mit ALLEN JOINS) - // ------------------------------------------------------------- - server.get("/resource/customers/:id", async (req, reply) => { - const { id } = req.params as { id: string } - const tenantId = req.user?.tenant_id - - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - // --- 1) Customer selbst laden - const customerRecord = await server.db - .select() - .from(customers) - .where(and(eq(customers.id, Number(id)), eq(customers.tenant, tenantId))) - .limit(1) - - if (!customerRecord.length) { - return reply.code(404).send({ error: "Customer not found" }) - } - - const customer = customerRecord[0] - - - // --- 2) Relations: - const [ - customerProjects, - customerPlants, - customerContracts, - customerContacts, - customerDocuments, - customerFiles, - customerEvents, - ] = await Promise.all([ - server.db.select().from(projects).where(eq(projects.customer, Number(id))), - server.db.select().from(plants).where(eq(plants.customer, Number(id))), - server.db.select().from(contracts).where(eq(contracts.customer, Number(id))), - server.db.select().from(contacts).where(eq(contacts.customer, Number(id))), - - server.db - .select({ - ...createddocuments, - allocations: statementallocations, - }) - .from(createddocuments) - .leftJoin( - statementallocations, - eq(statementallocations.cd_id, createddocuments.id) - ) - .where(eq(createddocuments.customer, Number(id))), - - server.db.select().from(files).where(eq(files.customer, Number(id))), - server.db.select().from(events).where(eq(events.customer, Number(id))), - ]) - - return { - ...customer, - projects: customerProjects, - plants: customerPlants, - contracts: customerContracts, - contacts: customerContacts, - createddocuments: customerDocuments, - files: customerFiles, - events: customerEvents, - } - }) -} diff --git a/src/routes/resources/main.ts b/src/routes/resources/main.ts new file mode 100644 index 0000000..0121e42 --- /dev/null +++ b/src/routes/resources/main.ts @@ -0,0 +1,413 @@ +import { FastifyInstance } from "fastify" +import { + eq, + ilike, + asc, + desc, + and, + count, + inArray, + or +} from "drizzle-orm" + + +import { + projects, + customers, + plants, + contracts, + projecttypes, + createddocuments, + files, + events, + tasks, contacts, vendors +} from "../../../db/schema" +import * as sea from "node:sea"; + +// ------------------------------------------------------------- +// SQL Volltextsuche auf mehreren Feldern +// ------------------------------------------------------------- + + +function buildSearchCondition(table: any, columns: string[], search: string) { + if (!search || !columns.length) return null + + const term = `%${search.toLowerCase()}%` + + const conditions = columns + .map((colName) => table[colName]) + .filter(Boolean) + .map((col) => ilike(col, term)) + + if (conditions.length === 0) return null + + // @ts-ignore + return or(...conditions) +} + +export default async function resourceRoutes(server: FastifyInstance) { + + // ------------------------------------------------------------- + // LIST + // ------------------------------------------------------------- + /*server.get("/resource/:resource", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) + return reply.code(400).send({ error: "No tenant selected" }) + + const { search, sort, asc: ascQuery } = req.query as { + search?: string + sort?: string + asc?: string + } + + // WHERE-Basis + let whereCond: any = eq(projects.tenant, tenantId) + + // 🔍 SQL Search + const searchCond = buildProjectSearch(search) + if (searchCond) whereCond = and(whereCond, searchCond) + + // Base Query + let q = server.db.select().from(projects).where(whereCond) + + // Sortierung + if (sort) { + const col = (projects as any)[sort] + if (col) { + q = ascQuery === "true" + ? q.orderBy(asc(col)) + : q.orderBy(desc(col)) + } + } + + const data = await q + return data + + } catch (err) { + console.error("ERROR /resource/projects", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + })*/ + + + // ------------------------------------------------------------- + // PAGINATED LIST + // ------------------------------------------------------------- + server.get("/resource/:resource/paginated", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id; + if (!tenantId) { + 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 { search, distinctColumns } = req.query as { + search?: string; + distinctColumns?: string; + }; + + + const config = { + projects: { + searchColumns: ["name"], + mtoLoad: ["customer","plant","contract","projecttype"], + table: projects + }, + customers: { + searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"], + table: customers, + }, + contacts: { + searchColumns: ["firstName", "lastName", "email", "phone", "notes"], + table: contacts, + mtoLoad: ["customer","vendor"] + }, + contracts: { + table: contracts, + searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"] + }, + plants: { + table: plants + }, + projecttypes: { + table: projecttypes + }, + vendors: { + table: vendors, + searchColumns: ["name","vendorNumber","notes","defaultPaymentType"], + }, + files: { + table: files + } + } + + let table = config[resource].table + + let whereCond: any = eq(table.tenant, tenantId); + + + if(search) { + const searchCond = buildSearchCondition( + table, + config[resource].searchColumns, + search.trim() + ) + + if (searchCond) { + whereCond = and(whereCond, searchCond) + } + } + + if (filters) { + for (const [key, val] of Object.entries(filters)) { + const col = (table as any)[key]; + if (!col) continue; + + if (Array.isArray(val)) { + whereCond = and(whereCond, inArray(col, val)); + } else { + whereCond = and(whereCond, eq(col, val as any)); + } + } + } + + // ----------------------------------------------- + // COUNT (for pagination) + // ----------------------------------------------- + const totalRes = await server.db + .select({ value: count(table.id) }) + .from(table) + .where(whereCond); + + const total = Number(totalRes[0]?.value ?? 0); + + // ----------------------------------------------- + // DISTINCT VALUES (regardless of pagination) + // ----------------------------------------------- + const distinctValues: Record = {}; + + if (distinctColumns) { + for (const colName of distinctColumns.split(",").map(c => c.trim())) { + const col = (table as any)[colName]; + if (!col) continue; + + const rows = await server.db + .select({ v: col }) + .from(table) + .where(eq(table.tenant, tenantId)); + + const values = rows + .map(r => r.v) + .filter(v => v != null && v !== ""); + + distinctValues[colName] = [...new Set(values)].sort(); + } + } + + // PAGINATION + const offset = pagination?.offset ?? 0; + const limit = pagination?.limit ?? 100; + + // SORTING + let orderField: any = null; + let direction: "asc" | "desc" = "asc"; + + if (sort?.length > 0) { + const s = sort[0]; + const col = (projects as any)[s.field]; + if (col) { + orderField = col; + direction = s.direction === "asc" ? "asc" : "desc"; + } + } + + // MAIN QUERY (Paginated) + let q = server.db + .select() + .from(table) + .where(whereCond) + .offset(offset) + .limit(limit); + + if (orderField) { + //@ts-ignore + q = direction === "asc" + ? q.orderBy(asc(orderField)) + : q.orderBy(desc(orderField)); + } + + const rows = await q; + + if (!rows.length) { + return { + data: [], + queryConfig: { + ...queryConfig, + total, + totalPages: 0, + distinctValues + } + }; + } + + // RELATION LOADING (MANY-TO-ONE) + + let ids = {} + let lists = {} + let maps = {} + let data = [] + + if(config[resource].mtoLoad) { + config[resource].mtoLoad.forEach(relation => { + ids[relation] = [...new Set(rows.map(r => r[relation]).filter(Boolean))]; + }) + + for await (const relation of config[resource].mtoLoad ) { + lists[relation] = ids[relation].length ? await server.db.select().from(config[relation + "s"].table).where(inArray(config[relation + "s"].table.id, ids[relation])) : [] + + } + + config[resource].mtoLoad.forEach(relation => { + maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i])); + }) + + data = rows.map(row => { + let toReturn = { + ...row + } + + config[resource].mtoLoad.forEach(relation => { + toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null + }) + + return toReturn + }); + } + + // ----------------------------------------------- + // RETURN DATA + // ----------------------------------------------- + return { + data, + queryConfig: { + ...queryConfig, + total, + totalPages: Math.ceil(total / limit), + distinctValues + } + }; + + } catch (err) { + console.error(`ERROR /resource/:resource/paginated:`, err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + + // ------------------------------------------------------------- + // DETAIL (mit JOINS) + // ------------------------------------------------------------- + /*server.get("/resource/projects/:id", async (req, reply) => { + try { + const { id } = req.params as { id: string } + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const pid = Number(id) + + const projRows = await server.db + .select() + .from(projects) + .where(and(eq(projects.id, pid), eq(projects.tenant, tenantId))) + .limit(1) + + if (!projRows.length) + return reply.code(404).send({ error: "Project not found" }) + + const project = projRows[0] + + // ------------------------------------ + // LOAD RELATIONS + // ------------------------------------ + const [ + customerRecord, + plantRecord, + contractRecord, + projectTypeRecord, + projectTasks, + projectFiles, + projectDocuments, + projectEvents, + ] = await Promise.all([ + + project.customer + ? server.db.select().from(customers).where(eq(customers.id, project.customer)) + : [], + + project.plant + ? server.db.select().from(plants).where(eq(plants.id, project.plant)) + : [], + + project.contract + ? server.db.select().from(contracts).where(eq(contracts.id, project.contract)) + : [], + + project.projecttype + ? server.db.select().from(projecttypes).where(eq(projecttypes.id, project.projecttype)) + : [], + + // Tasks + server.db + .select() + .from(tasks) + .where(eq(tasks.project, pid)), + + // Files + server.db + .select() + .from(files) + .where(eq(files.project, pid)), + + // Documents + server.db + .select() + .from(createddocuments) + .where(eq(createddocuments.project, pid)), + + // Events + server.db + .select() + .from(events) + .where(eq(events.project, pid)), + + ]) + + return { + ...project, + customer: customerRecord[0] ?? null, + plant: plantRecord[0] ?? null, + contract: contractRecord[0] ?? null, + projecttype: projectTypeRecord[0] ?? null, + tasks: projectTasks, + files: projectFiles, + createddocuments: projectDocuments, + events: projectEvents, + } + + } catch (err) { + console.error("ERROR /resource/projects/:id", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + })*/ +} diff --git a/src/routes/resources/productsServices.ts b/src/routes/resources/productsServices.ts new file mode 100644 index 0000000..dd25bb5 --- /dev/null +++ b/src/routes/resources/productsServices.ts @@ -0,0 +1,858 @@ +import { FastifyInstance } from "fastify" +import { + eq, + ilike, + asc, + desc, + and, + count, + inArray, +} from "drizzle-orm" + +import { + products, + productcategories, + services, + servicecategories, +} from "../../../db/schema" + +// ----------------------------------------------------------------------------- +// PRODUCTS +// ----------------------------------------------------------------------------- +export default async function productsAndServicesRoutes(server: FastifyInstance) { + // ------------------------------------------------------------- + // LIST: /resource/products + // ------------------------------------------------------------- + server.get("/resource/products", async (req, reply) => { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const { search, sort, asc: ascQuery } = req.query as { + search?: string + sort?: string + asc?: string + } + + let baseQuery = server.db + .select() + .from(products) + .where(eq(products.tenant, tenantId)) + + if (search) { + baseQuery = server.db + .select() + .from(products) + .where( + and( + eq(products.tenant, tenantId), + ilike(products.name, `%${search}%`) + ) + ) + } + + if (sort) { + const field = (products as any)[sort] + if (field) { + // @ts-ignore + baseQuery = baseQuery.orderBy( + ascQuery === "true" ? asc(field) : desc(field) + ) + } + } + + const list = await baseQuery + return list + }) + + // ------------------------------------------------------------- + // PAGINATED: /resource/products/paginated + // ------------------------------------------------------------- + server.get("/resource/products/paginated", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const queryConfig = req.queryConfig + const { + pagination, + sort, + filters, + paginationDisabled, + } = queryConfig + + const { + select, // aktuell ignoriert, wie bei customers + search, + searchColumns, + distinctColumns, + } = req.query as { + select?: string + search?: string + searchColumns?: string + distinctColumns?: string + } + + let whereCond: any = eq(products.tenant, tenantId) + + if (filters) { + for (const [key, val] of Object.entries(filters)) { + const col = (products as any)[key] + if (!col) continue + + if (Array.isArray(val)) { + whereCond = and(whereCond, inArray(col, val)) + } else if (val === true || val === false || val === null) { + whereCond = and(whereCond, eq(col, val as any)) + } else { + whereCond = and(whereCond, eq(col, val as any)) + } + } + } + + if (search && search.trim().length > 0) { + const searchTerm = `%${search.trim().toLowerCase()}%` + whereCond = and( + whereCond, + ilike(products.name, searchTerm) + ) + } + + const totalRes = await server.db + .select({ value: count(products.id) }) + .from(products) + .where(whereCond) + + const total = Number(totalRes[0]?.value ?? 0) + + const distinctValues: Record = {} + + if (distinctColumns) { + for (const colName of distinctColumns.split(",").map(v => v.trim())) { + const col = (products as any)[colName] + if (!col) continue + + const rows = await server.db + .select({ v: col }) + .from(products) + .where(eq(products.tenant, tenantId)) + + const values = rows + .map(r => r.v) + .filter(v => v != null && v !== "") + + distinctValues[colName] = [...new Set(values)].sort() + } + } + + let offset = 0 + let limit = 999999 + + if (!paginationDisabled && pagination) { + offset = pagination.offset + limit = pagination.limit + } + + let orderField: any = null + let orderDirection: "asc" | "desc" = "asc" + + if (sort?.length > 0) { + const s = sort[0] + const col = (products as any)[s.field] + if (col) { + orderField = col + orderDirection = s.direction === "asc" ? "asc" : "desc" + } + } + + let dataQuery = server.db + .select() + .from(products) + .where(whereCond) + .offset(offset) + .limit(limit) + + if (orderField) { + dataQuery = + orderDirection === "asc" + ? dataQuery.orderBy(asc(orderField)) + : dataQuery.orderBy(desc(orderField)) + } + + const data = await dataQuery + + const totalPages = pagination?.limit + ? Math.ceil(total / pagination.limit) + : 1 + + const enrichedConfig = { + ...queryConfig, + total, + totalPages, + distinctValues, + search: search || null, + } + + return { + data, + queryConfig: enrichedConfig, + } + } catch (e) { + server.log.error(e) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + // ------------------------------------------------------------- + // DETAIL: /resource/products/:id + // (aktuell ohne weitere Joins) + // ------------------------------------------------------------- + server.get("/resource/products/:id", async (req, reply) => { + const { id } = req.params as { id: string } + const tenantId = req.user?.tenant_id + + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const rows = await server.db + .select() + .from(products) + .where( + and( + eq(products.id, Number(id)), + eq(products.tenant, tenantId) + ) + ) + .limit(1) + + if (!rows.length) { + return reply.code(404).send({ error: "Product not found" }) + } + + return rows[0] + }) + + + // --------------------------------------------------------------------------- + // PRODUCTCATEGORIES + // --------------------------------------------------------------------------- + server.get("/resource/productcategories", async (req, reply) => { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const { search, sort, asc: ascQuery } = req.query as { + search?: string + sort?: string + asc?: string + } + + let baseQuery = server.db + .select() + .from(productcategories) + .where(eq(productcategories.tenant, tenantId)) + + if (search) { + const searchTerm = `%${search}%` + baseQuery = server.db + .select() + .from(productcategories) + .where( + and( + eq(productcategories.tenant, tenantId), + ilike(productcategories.name, searchTerm) + ) + ) + } + + if (sort) { + const field = (productcategories as any)[sort] + if (field) { + // @ts-ignore + baseQuery = baseQuery.orderBy( + ascQuery === "true" ? asc(field) : desc(field) + ) + } + } + + const list = await baseQuery + return list + }) + + server.get("/resource/productcategories/paginated", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const queryConfig = req.queryConfig + const { + pagination, + sort, + filters, + paginationDisabled, + } = queryConfig + + const { + select, + search, + searchColumns, + distinctColumns, + } = req.query as { + select?: string + search?: string + searchColumns?: string + distinctColumns?: string + } + + let whereCond: any = eq(productcategories.tenant, tenantId) + + if (filters) { + for (const [key, val] of Object.entries(filters)) { + const col = (productcategories as any)[key] + if (!col) continue + + if (Array.isArray(val)) { + whereCond = and(whereCond, inArray(col, val)) + } else if (val === true || val === false || val === null) { + whereCond = and(whereCond, eq(col, val as any)) + } else { + whereCond = and(whereCond, eq(col, val as any)) + } + } + } + + if (search && search.trim().length > 0) { + const searchTerm = `%${search.trim().toLowerCase()}%` + whereCond = and( + whereCond, + ilike(productcategories.name, searchTerm) + ) + } + + const totalRes = await server.db + .select({ value: count(productcategories.id) }) + .from(productcategories) + .where(whereCond) + + const total = Number(totalRes[0]?.value ?? 0) + + const distinctValues: Record = {} + + if (distinctColumns) { + for (const colName of distinctColumns.split(",").map(v => v.trim())) { + const col = (productcategories as any)[colName] + if (!col) continue + + const rows = await server.db + .select({ v: col }) + .from(productcategories) + .where(eq(productcategories.tenant, tenantId)) + + const values = rows + .map(r => r.v) + .filter(v => v != null && v !== "") + + distinctValues[colName] = [...new Set(values)].sort() + } + } + + let offset = 0 + let limit = 999999 + + if (!paginationDisabled && pagination) { + offset = pagination.offset + limit = pagination.limit + } + + let orderField: any = null + let orderDirection: "asc" | "desc" = "asc" + + if (sort?.length > 0) { + const s = sort[0] + const col = (productcategories as any)[s.field] + if (col) { + orderField = col + orderDirection = s.direction === "asc" ? "asc" : "desc" + } + } + + let dataQuery = server.db + .select() + .from(productcategories) + .where(whereCond) + .offset(offset) + .limit(limit) + + if (orderField) { + dataQuery = + orderDirection === "asc" + ? dataQuery.orderBy(asc(orderField)) + : dataQuery.orderBy(desc(orderField)) + } + + const data = await dataQuery + + const totalPages = pagination?.limit + ? Math.ceil(total / pagination.limit) + : 1 + + const enrichedConfig = { + ...queryConfig, + total, + totalPages, + distinctValues, + search: search || null, + } + + return { + data, + queryConfig: enrichedConfig, + } + } catch (e) { + server.log.error(e) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + server.get("/resource/productcategories/:id", async (req, reply) => { + const { id } = req.params as { id: string } + const tenantId = req.user?.tenant_id + + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const rows = await server.db + .select() + .from(productcategories) + .where( + and( + eq(productcategories.id, Number(id)), + eq(productcategories.tenant, tenantId) + ) + ) + .limit(1) + + if (!rows.length) { + return reply.code(404).send({ error: "Product category not found" }) + } + + // Später hier: products mit Join-Tabelle + return rows[0] + }) + + + // --------------------------------------------------------------------------- + // SERVICES + // --------------------------------------------------------------------------- + server.get("/resource/services", async (req, reply) => { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const { search, sort, asc: ascQuery } = req.query as { + search?: string + sort?: string + asc?: string + } + + let baseQuery = server.db + .select() + .from(services) + .where(eq(services.tenant, tenantId)) + + if (search) { + const searchTerm = `%${search}%` + baseQuery = server.db + .select() + .from(services) + .where( + and( + eq(services.tenant, tenantId), + ilike(services.name, searchTerm) + ) + ) + } + + if (sort) { + const field = (services as any)[sort] + if (field) { + // @ts-ignore + baseQuery = baseQuery.orderBy( + ascQuery === "true" ? asc(field) : desc(field) + ) + } + } + + const list = await baseQuery + return list + }) + + server.get("/resource/services/paginated", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const queryConfig = req.queryConfig + const { + pagination, + sort, + filters, + paginationDisabled, + } = queryConfig + + const { + select, + search, + searchColumns, + distinctColumns, + } = req.query as { + select?: string + search?: string + searchColumns?: string + distinctColumns?: string + } + + let whereCond: any = eq(services.tenant, tenantId) + + if (filters) { + for (const [key, val] of Object.entries(filters)) { + const col = (services as any)[key] + if (!col) continue + + if (Array.isArray(val)) { + whereCond = and(whereCond, inArray(col, val)) + } else if (val === true || val === false || val === null) { + whereCond = and(whereCond, eq(col, val as any)) + } else { + whereCond = and(whereCond, eq(col, val as any)) + } + } + } + + if (search && search.trim().length > 0) { + const searchTerm = `%${search.trim().toLowerCase()}%` + whereCond = and( + whereCond, + ilike(services.name, searchTerm) + ) + } + + const totalRes = await server.db + .select({ value: count(services.id) }) + .from(services) + .where(whereCond) + + const total = Number(totalRes[0]?.value ?? 0) + + const distinctValues: Record = {} + + if (distinctColumns) { + for (const colName of distinctColumns.split(",").map(v => v.trim())) { + const col = (services as any)[colName] + if (!col) continue + + const rows = await server.db + .select({ v: col }) + .from(services) + .where(eq(services.tenant, tenantId)) + + const values = rows + .map(r => r.v) + .filter(v => v != null && v !== "") + + distinctValues[colName] = [...new Set(values)].sort() + } + } + + let offset = 0 + let limit = 999999 + + if (!paginationDisabled && pagination) { + offset = pagination.offset + limit = pagination.limit + } + + let orderField: any = null + let orderDirection: "asc" | "desc" = "asc" + + if (sort?.length > 0) { + const s = sort[0] + const col = (services as any)[s.field] + if (col) { + orderField = col + orderDirection = s.direction === "asc" ? "asc" : "desc" + } + } + + let dataQuery = server.db + .select() + .from(services) + .where(whereCond) + .offset(offset) + .limit(limit) + + if (orderField) { + dataQuery = + orderDirection === "asc" + ? dataQuery.orderBy(asc(orderField)) + : dataQuery.orderBy(desc(orderField)) + } + + const data = await dataQuery + + const totalPages = pagination?.limit + ? Math.ceil(total / pagination.limit) + : 1 + + const enrichedConfig = { + ...queryConfig, + total, + totalPages, + distinctValues, + search: search || null, + } + + return { + data, + queryConfig: enrichedConfig, + } + } catch (e) { + server.log.error(e) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + server.get("/resource/services/:id", async (req, reply) => { + const { id } = req.params as { id: string } + const tenantId = req.user?.tenant_id + + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const rows = await server.db + .select() + .from(services) + .where( + and( + eq(services.id, Number(id)), + eq(services.tenant, tenantId) + ) + ) + .limit(1) + + if (!rows.length) { + return reply.code(404).send({ error: "Service not found" }) + } + + // Später: Unit, Kategorien, etc. als Joins + return rows[0] + }) + + + // --------------------------------------------------------------------------- + // SERVICECATEGORIES + // --------------------------------------------------------------------------- + server.get("/resource/servicecategories", async (req, reply) => { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const { search, sort, asc: ascQuery } = req.query as { + search?: string + sort?: string + asc?: string + } + + let baseQuery = server.db + .select() + .from(servicecategories) + .where(eq(servicecategories.tenant, tenantId)) + + if (search) { + const searchTerm = `%${search}%` + baseQuery = server.db + .select() + .from(servicecategories) + .where( + and( + eq(servicecategories.tenant, tenantId), + ilike(servicecategories.name, searchTerm) + ) + ) + } + + if (sort) { + const field = (servicecategories as any)[sort] + if (field) { + // @ts-ignore + baseQuery = baseQuery.orderBy( + ascQuery === "true" ? asc(field) : desc(field) + ) + } + } + + const list = await baseQuery + return list + }) + + server.get("/resource/servicecategories/paginated", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const queryConfig = req.queryConfig + const { + pagination, + sort, + filters, + paginationDisabled, + } = queryConfig + + const { + select, + search, + searchColumns, + distinctColumns, + } = req.query as { + select?: string + search?: string + searchColumns?: string + distinctColumns?: string + } + + let whereCond: any = eq(servicecategories.tenant, tenantId) + + if (filters) { + for (const [key, val] of Object.entries(filters)) { + const col = (servicecategories as any)[key] + if (!col) continue + + if (Array.isArray(val)) { + whereCond = and(whereCond, inArray(col, val)) + } else if (val === true || val === false || val === null) { + whereCond = and(whereCond, eq(col, val as any)) + } else { + whereCond = and(whereCond, eq(col, val as any)) + } + } + } + + if (search && search.trim().length > 0) { + const searchTerm = `%${search.trim().toLowerCase()}%` + whereCond = and( + whereCond, + ilike(servicecategories.name, searchTerm) + ) + } + + const totalRes = await server.db + .select({ value: count(servicecategories.id) }) + .from(servicecategories) + .where(whereCond) + + const total = Number(totalRes[0]?.value ?? 0) + + const distinctValues: Record = {} + + if (distinctColumns) { + for (const colName of distinctColumns.split(",").map(v => v.trim())) { + const col = (servicecategories as any)[colName] + if (!col) continue + + const rows = await server.db + .select({ v: col }) + .from(servicecategories) + .where(eq(servicecategories.tenant, tenantId)) + + const values = rows + .map(r => r.v) + .filter(v => v != null && v !== "") + + distinctValues[colName] = [...new Set(values)].sort() + } + } + + let offset = 0 + let limit = 999999 + + if (!paginationDisabled && pagination) { + offset = pagination.offset + limit = pagination.limit + } + + let orderField: any = null + let orderDirection: "asc" | "desc" = "asc" + + if (sort?.length > 0) { + const s = sort[0] + const col = (servicecategories as any)[s.field] + if (col) { + orderField = col + orderDirection = s.direction === "asc" ? "asc" : "desc" + } + } + + let dataQuery = server.db + .select() + .from(servicecategories) + .where(whereCond) + .offset(offset) + .limit(limit) + + if (orderField) { + dataQuery = + orderDirection === "asc" + ? dataQuery.orderBy(asc(orderField)) + : dataQuery.orderBy(desc(orderField)) + } + + const data = await dataQuery + + const totalPages = pagination?.limit + ? Math.ceil(total / pagination.limit) + : 1 + + const enrichedConfig = { + ...queryConfig, + total, + totalPages, + distinctValues, + search: search || null, + } + + return { + data, + queryConfig: enrichedConfig, + } + } catch (e) { + server.log.error(e) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + server.get("/resource/servicecategories/:id", async (req, reply) => { + const { id } = req.params as { id: string } + const tenantId = req.user?.tenant_id + + if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) + + const rows = await server.db + .select() + .from(servicecategories) + .where( + and( + eq(servicecategories.id, Number(id)), + eq(servicecategories.tenant, tenantId) + ) + ) + .limit(1) + + if (!rows.length) { + return reply.code(404).send({ error: "Service category not found" }) + } + + // Später: zugehörige Services über Join-Tabelle + return rows[0] + }) +} diff --git a/src/routes/resources/vendors.ts b/src/routes/resources/vendors.ts deleted file mode 100644 index 4408f53..0000000 --- a/src/routes/resources/vendors.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { FastifyInstance } from "fastify" -import { - eq, - ilike, - asc, - desc, - and, - count, - inArray, - or -} from "drizzle-orm" - -import { - vendors, - contacts, - files, -} from "../../../db/schema" - -// ------------------------------------------------------------- -// SQL Volltext-Suche (über mehrere relevante Felder) -// ------------------------------------------------------------- -function buildVendorSearchTerm(search?: string) { - if (!search) return null - - const term = `%${search.toLowerCase()}%` - - return or( - ilike(vendors.name, term), - ilike(vendors.vendorNumber, term), - ilike(vendors.notes, term), - ilike(vendors.defaultPaymentMethod, term) - ) -} - -export default async function vendorRoutes(server: FastifyInstance) { - - // ------------------------------------------------------------- - // LIST - // ------------------------------------------------------------- - server.get("/resource/vendors", async (req, reply) => { - try { - const tenantId = req.user?.tenant_id - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const { search, sort, asc: ascQuery } = req.query as { - search?: string - sort?: string - asc?: string - } - - // WHERE - let whereCond: any = eq(vendors.tenant, tenantId) - - const searchCond = buildVendorSearchTerm(search) - if (searchCond) whereCond = and(whereCond, searchCond) - - // QUERY - let q = server.db - .select() - .from(vendors) - .where(whereCond) - - // SORT - if (sort) { - const col = (vendors as any)[sort] - if (col) { - //@ts-ignore - q = ascQuery === "true" - ? q.orderBy(asc(col)) - : q.orderBy(desc(col)) - } - } - - return await q - } catch (err) { - console.error("ERROR /resource/vendors:", err) - return reply.code(500).send({ error: "Internal Server Error" }) - } - }) - - - // ------------------------------------------------------------- - // PAGINATED - // ------------------------------------------------------------- - server.get("/resource/vendors/paginated", async (req, reply) => { - try { - const tenantId = req.user?.tenant_id - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const queryConfig = req.queryConfig - const { pagination, sort, filters, paginationDisabled } = queryConfig - - const { search, distinctColumns } = req.query as { - search?: string - distinctColumns?: string - } - - // ------------------------------------ - // WHERE - // ------------------------------------ - let whereCond: any = eq(vendors.tenant, tenantId) - - // Filters - if (filters) { - for (const [key, val] of Object.entries(filters)) { - const col = (vendors as any)[key] - if (!col) continue - - if (Array.isArray(val)) { - whereCond = and(whereCond, inArray(col, val)) - } else { - whereCond = and(whereCond, eq(col, val as any)) - } - } - } - - // SEARCH - const searchCond = buildVendorSearchTerm(search) - if (searchCond) whereCond = and(whereCond, searchCond) - - // ------------------------------------ - // COUNT - // ------------------------------------ - const totalRes = await server.db - .select({ value: count(vendors.id) }) - .from(vendors) - .where(whereCond) - - const total = Number(totalRes[0]?.value ?? 0) - - // ------------------------------------ - // DISTINCT VALUES - // ------------------------------------ - const distinctValues: Record = {} - - if (distinctColumns) { - for (const field of distinctColumns.split(",")) { - const col = (vendors as any)[field.trim()] - if (!col) continue - - const rows = await server.db - .select({ v: col }) - .from(vendors) - .where(eq(vendors.tenant, tenantId)) - - const values = rows - .map(r => r.v) - .filter(v => v !== null && v !== "") - - distinctValues[field] = [...new Set(values)].sort() - } - } - - // ------------------------------------ - // PAGINATION - // ------------------------------------ - const offset = pagination?.offset ?? 0 - const limit = pagination?.limit ?? 5000 - - // ------------------------------------ - // SORT - // ------------------------------------ - let orderField: any = null - let orderDir: "asc" | "desc" = "asc" - - if (sort?.length > 0) { - const s = sort[0] - const col = (vendors as any)[s.field] - if (col) { - orderField = col - orderDir = s.direction === "asc" ? "asc" : "desc" - } - } - - // ------------------------------------ - // DATA QUERY - // ------------------------------------ - let q = server.db - .select() - .from(vendors) - .where(whereCond) - .offset(offset) - .limit(limit) - - if (orderField) { - //@ts-ignore - q = orderDir === "asc" - ? q.orderBy(asc(orderField)) - : q.orderBy(desc(orderField)) - } - - const data = await q - - return { - data, - queryConfig: { - ...queryConfig, - total, - totalPages: pagination?.limit - ? Math.ceil(total / pagination.limit) - : 1, - distinctValues, - search: search || null - } - } - - } catch (err) { - console.error("ERROR /resource/vendors/paginated:", err) - return reply.code(500).send({ error: "Internal Server Error" }) - } - }) - - - // ------------------------------------------------------------- - // DETAIL (mit JOINs: contacts, files) - // ------------------------------------------------------------- - server.get("/resource/vendors/:id", async (req, reply) => { - try { - const { id } = req.params as { id: string } - const tenantId = req.user?.tenant_id - if (!tenantId) return reply.code(400).send({ error: "No tenant selected" }) - - const vendorId = Number(id) - - const vendorRows = await server.db - .select() - .from(vendors) - .where(and(eq(vendors.id, vendorId), eq(vendors.tenant, tenantId))) - .limit(1) - - if (!vendorRows.length) return reply.code(404).send({ error: "Vendor not found" }) - - const vendor = vendorRows[0] - - const [vendorContacts, vendorFiles] = await Promise.all([ - server.db.select().from(contacts).where(eq(contacts.vendor, vendorId)), - server.db.select().from(files).where(eq(files.vendor, vendorId)) - ]) - - return { - ...vendor, - contacts: vendorContacts, - files: vendorFiles - } - - } catch (err) { - console.error("ERROR /resource/vendors/:id:", err) - return reply.code(500).send({ error: "Internal Server Error" }) - } - }) -}