From 1d3bf94b8895864c5a455665fbe526bbd47f6b45 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 8 Dec 2025 11:45:50 +0100 Subject: [PATCH] Route Changes --- src/routes/banking.ts | 293 +++++++++++++------------- src/routes/resources/main.ts | 67 ++++-- src/routes/tenant.ts | 386 ++++++++++++++++++++--------------- 3 files changed, 430 insertions(+), 316 deletions(-) diff --git a/src/routes/banking.ts b/src/routes/banking.ts index fd228b7..f06ab60 100644 --- a/src/routes/banking.ts +++ b/src/routes/banking.ts @@ -1,217 +1,236 @@ -import { FastifyInstance } from "fastify"; -import {insertHistoryItem} from "../utils/history"; +import { FastifyInstance } from "fastify" import axios from "axios" import dayjs from "dayjs" -import {secrets} from "../utils/secrets"; + +import { secrets } from "../utils/secrets" +import { insertHistoryItem } from "../utils/history" + +import { + bankrequisitions, + statementallocations, +} from "../../db/schema" + +import { + eq, + and, +} from "drizzle-orm" + export default async function bankingRoutes(server: FastifyInstance) { + // ------------------------------------------------------------------ + // 🔐 GoCardLess Token Handling + // ------------------------------------------------------------------ + const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY - let tokenData = null + let tokenData: any = null const getToken = async () => { - const res = await axios({ - url: goCardLessBaseUrl + "/token/new/", - method: "POST", - data: { - secret_id: goCardLessSecretId, - secret_key: goCardLessSecretKey, - }, + const res = await axios.post(`${goCardLessBaseUrl}/token/new/`, { + secret_id: goCardLessSecretId, + secret_key: goCardLessSecretKey, }) tokenData = res.data tokenData.created_at = new Date().toISOString() - server.log.info("Got new GoCardless token") + + server.log.info("GoCardless token refreshed.") } const checkToken = async () => { - if (tokenData) { - const expired = dayjs(tokenData.created_at) - .add(tokenData.access_expires, "seconds") - .isBefore(dayjs()) - if (expired) { - server.log.info("Token expired — refreshing…") - await getToken() - } - } else { + if (!tokenData) return await getToken() + + const expired = dayjs(tokenData.created_at) + .add(tokenData.access_expires, "seconds") + .isBefore(dayjs()) + + if (expired) { + server.log.info("Refreshing expired GoCardless token …") await getToken() } } - // 🔹 Generate Link + // ------------------------------------------------------------------ + // 🔗 Create GoCardless Banking Link + // ------------------------------------------------------------------ server.get("/banking/link/:institutionid", async (req, reply) => { - await checkToken() - - const {institutionid} = req.params as {institutionid: string} - try { - const { data } = await axios({ - url: `${goCardLessBaseUrl}/requisitions/`, - method: "POST", - headers: { - Authorization: `Bearer ${tokenData.access}`, - accept: "application/json", - }, - data: { + await checkToken() + + const { institutionid } = req.params as { institutionid: string } + const tenantId = req.user?.tenant_id + + if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) + + const { data } = await axios.post( + `${goCardLessBaseUrl}/requisitions/`, + { redirect: "https://app.fedeo.de/settings/banking", institution_id: institutionid, user_language: "de", }, + { + headers: { Authorization: `Bearer ${tokenData.access}` }, + } + ) + + // DB: Requisition speichern + await server.db.insert(bankrequisitions).values({ + id: data.id, + tenant: tenantId, + institutionId: institutionid, + status: data.status, }) - await server.supabase - .from("bankrequisitions") - .insert({ - tenant: req.user.tenant_id, - institutionId: institutionid, - id: data.id, - status: data.status, - }) - return reply.send({ link: data.link }) - } catch (err) { - server.log.error(err.response?.data || err.message) + } catch (err: any) { + server.log.error(err?.response?.data || err) return reply.code(500).send({ error: "Failed to generate link" }) } }) - // 🔹 Check Institution + // ------------------------------------------------------------------ + // 🏦 Check Bank Institutions + // ------------------------------------------------------------------ server.get("/banking/institutions/:bic", async (req, reply) => { - const { bic } = req.params as {bic: string} - if (!bic) return reply.code(400).send("BIC not provided") - - await checkToken() - try { - const { data } = await axios({ - url: `${goCardLessBaseUrl}/institutions/?country=de`, - method: "GET", - headers: { - Authorization: `Bearer ${tokenData.access}`, - }, - }) + const { bic } = req.params as { bic: string } + if (!bic) return reply.code(400).send("BIC missing") + + await checkToken() + + const { data } = await axios.get( + `${goCardLessBaseUrl}/institutions/?country=de`, + { headers: { Authorization: `Bearer ${tokenData.access}` } } + ) + + const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase()) - const bank = data.find((i) => i.bic.toLowerCase() === bic.toLowerCase()) if (!bank) return reply.code(404).send("Bank not found") return reply.send(bank) - } catch (err) { - server.log.error(err.response?.data || err.message) + } catch (err: any) { + server.log.error(err?.response?.data || err) return reply.code(500).send("Failed to fetch institutions") } }) - // 🔹 List Requisitions + + // ------------------------------------------------------------------ + // 📄 Get Requisition Details + // ------------------------------------------------------------------ server.get("/banking/requisitions/:reqId", async (req, reply) => { - const { reqId } = req.params as {reqId: string} - if (!reqId) return reply.code(400).send("Requisition ID not provided") - - await checkToken() - try { - const { data } = await axios({ - url: `${goCardLessBaseUrl}/requisitions/${reqId}`, - method: "GET", - headers: { - Authorization: `Bearer ${tokenData.access}`, - }, - }) + const { reqId } = req.params as { reqId: string } + if (!reqId) return reply.code(400).send("Requisition ID missing") + await checkToken() + + const { data } = await axios.get( + `${goCardLessBaseUrl}/requisitions/${reqId}`, + { headers: { Authorization: `Bearer ${tokenData.access}` } } + ) + + // Load account details if (data.accounts) { data.accounts = await Promise.all( - data.accounts.map(async (accId) => { - const { data: accountData } = await axios({ - url: `${goCardLessBaseUrl}/accounts/${accId}`, - method: "GET", - headers: { - Authorization: `Bearer ${tokenData.access}`, - accept: "application/json", - }, - }) - return accountData + data.accounts.map(async (accId: string) => { + const { data: acc } = await axios.get( + `${goCardLessBaseUrl}/accounts/${accId}`, + { headers: { Authorization: `Bearer ${tokenData.access}` } } + ) + return acc }) ) } return reply.send(data) - } catch (err) { - server.log.error(err.response?.data || err.message) - return reply.code(500).send("Failed to fetch requisition data") + } catch (err: any) { + server.log.error(err?.response?.data || err) + return reply.code(500).send("Failed to fetch requisition details") } }) - //Create Banking Statement + // ------------------------------------------------------------------ + // 💰 Create Statement Allocation + // ------------------------------------------------------------------ server.post("/banking/statements", async (req, reply) => { - if (!req.user) { - return reply.code(401).send({ error: "Unauthorized" }); + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) + + const { data: payload } = req.body as { data: any } + + const inserted = await server.db.insert(statementallocations).values({ + ...payload, + tenant: req.user.tenant_id + }).returning() + + const createdRecord = inserted[0] + + await insertHistoryItem(server, { + entity: "bankstatements", + entityId: createdRecord.id, + action: "created", + created_by: req.user.user_id, + tenant_id: req.user.tenant_id, + oldVal: null, + newVal: createdRecord, + text: "Buchung erstellt", + }) + + return reply.send(createdRecord) + + } catch (err) { + console.error(err) + return reply.code(500).send({ error: "Failed to create statement" }) } + }) - const body = req.body as { data: string }; - console.log(body); - const {data,error} = await server.supabase.from("statementallocations").insert({ - //@ts-ignore - ...body.data, - tenant: req.user.tenant_id, - }).select() - - await insertHistoryItem(server,{ - entity: "bankstatements", - //@ts-ignore - entityId: data.id, - action: "created", - created_by: req.user.user_id, - tenant_id: req.user.tenant_id, - oldVal: null, - newVal: data, - text: `Buchung erstellt`, - }); - - if(data && !error){ - return reply.send(data) - } - }); - - //Delete Banking Statement + // ------------------------------------------------------------------ + // 🗑 Delete Statement Allocation + // ------------------------------------------------------------------ server.delete("/banking/statements/:id", async (req, reply) => { - if (!req.user) { - return reply.code(401).send({ error: "Unauthorized" }); - } + try { + if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) - const { id } = req.params as { id?: string } + const { id } = req.params as { id: string } - const {data} = await server.supabase.from("statementallocations").select().eq("id",id).single() + const oldRecord = await server.db + .select() + .from(statementallocations) + .where(eq(statementallocations.id, id)) + .limit(1) - const {error} = await server.supabase.from("statementallocations").delete().eq("id",id) + const old = oldRecord[0] - if(!error){ + if (!old) return reply.code(404).send({ error: "Record not found" }) - await insertHistoryItem(server,{ + await server.db + .delete(statementallocations) + .where(eq(statementallocations.id, id)) + + await insertHistoryItem(server, { entity: "bankstatements", entityId: id, action: "deleted", created_by: req.user.user_id, tenant_id: req.user.tenant_id, - oldVal: data, + oldVal: old, newVal: null, - text: `Buchung gelöscht`, - }); + text: "Buchung gelöscht", + }) - return reply.send({success:true}) - } else { - return reply.code(500).send({error:"Fehler beim löschen"}) + return reply.send({ success: true }) + + } catch (err) { + console.error(err) + return reply.code(500).send({ error: "Failed to delete statement" }) } - - }) - - - - } - diff --git a/src/routes/resources/main.ts b/src/routes/resources/main.ts index 514aa9f..bad18a7 100644 --- a/src/routes/resources/main.ts +++ b/src/routes/resources/main.ts @@ -87,13 +87,12 @@ export default async function resourceRoutes(server: FastifyInstance) { const queryData = await q - // RELATION LOADING (MANY-TO-ONE) let ids = {} let lists = {} let maps = {} - let data = [] + let data = [...queryData] if(resourceConfig[resource].mtoLoad) { resourceConfig[resource].mtoLoad.forEach(relation => { @@ -101,6 +100,7 @@ export default async function resourceRoutes(server: FastifyInstance) { }) for await (const relation of resourceConfig[resource].mtoLoad ) { + console.log(relation) lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : [] } @@ -120,8 +120,29 @@ export default async function resourceRoutes(server: FastifyInstance) { return toReturn }); - } else { - data = queryData + } + + if(resourceConfig[resource].mtmListLoad) { + for await (const relation of resourceConfig[resource].mtmListLoad) { + console.log(relation) + console.log(resource.substring(0,resource.length-1)) + + const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id))) + + console.log(relationRows.length) + + data = data.map(row => { + let toReturn = { + ...row + } + + toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id) + + return toReturn + }) + + + } } return data @@ -271,14 +292,14 @@ export default async function resourceRoutes(server: FastifyInstance) { }; } - // RELATION LOADING (MANY-TO-ONE) - let ids = {} - let lists = {} - let maps = {} - let data = [] + let data = [...rows] + //Many to One if(resourceConfig[resource].mtoLoad) { + let ids = {} + let lists = {} + let maps = {} resourceConfig[resource].mtoLoad.forEach(relation => { ids[relation] = [...new Set(rows.map(r => r[relation]).filter(Boolean))]; }) @@ -303,8 +324,28 @@ export default async function resourceRoutes(server: FastifyInstance) { return toReturn }); - } else { - data = rows + } + + if(resourceConfig[resource].mtmListLoad) { + for await (const relation of resourceConfig[resource].mtmListLoad) { + console.log(relation) + + const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id))) + + console.log(relationRows) + + data = data.map(row => { + let toReturn = { + ...row + } + + toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id) + + return toReturn + }) + + + } } // ----------------------------------------------- @@ -365,13 +406,13 @@ export default async function resourceRoutes(server: FastifyInstance) { data[relation] = await server.db.select().from(resourceConfig[relation + "s"].table).where(eq(resourceConfig[relation + "s"].table.id, data[relation])) } } + } + if(resourceConfig[resource].mtmLoad) { for await (const relation of resourceConfig[resource].mtmLoad ) { console.log(relation) data[relation] = await server.db.select().from(resourceConfig[relation].table).where(eq(resourceConfig[relation].table[resource.substring(0,resource.length - 1)],id)) } - - } return data diff --git a/src/routes/tenant.ts b/src/routes/tenant.ts index 36634ab..8b83203 100644 --- a/src/routes/tenant.ts +++ b/src/routes/tenant.ts @@ -1,188 +1,242 @@ -import { FastifyInstance } from "fastify"; -import jwt from "jsonwebtoken"; -import {secrets} from "../utils/secrets"; +import { FastifyInstance } from "fastify" +import jwt from "jsonwebtoken" +import { secrets } from "../utils/secrets" -export default async function routes(server: FastifyInstance) { +import { + authTenantUsers, + authUsers, + authProfiles, + tenants +} from "../../db/schema" + +import { eq } from "drizzle-orm" + + +export default async function tenantRoutes(server: FastifyInstance) { + + + // ------------------------------------------------------------- + // GET CURRENT TENANT + // ------------------------------------------------------------- server.get("/tenant", async (req) => { - if(req.tenant) { + if (req.tenant) { return { message: `Hallo vom Tenant ${req.tenant?.name}`, tenant_id: req.tenant?.id, - }; - } else { - return { - message: `Server ist in MultiTenant Mode. Sie bekommen alles für Sie verfügbare`, - }; - } - - - - }); - - server.post("/tenant/switch", async (req, reply) => { - if (!req.user) { - return reply.code(401).send({ error: "Unauthorized" }); - } - - const body = req.body as { tenant_id: string }; - console.log(body); - - // prüfen ob user im Tenant Mitglied ist - const { data: tenantUser, error } = await server.supabase - .from("auth_tenant_users") - .select("*") - .eq("user_id", req.user.user_id) - .eq("tenant_id", body.tenant_id) - .single(); - - if (error || !tenantUser) { - return reply.code(403).send({ error: "Not a member of this tenant" }); - } - - // neues JWT mit tenant_id ausstellen - const token = jwt.sign( - { - user_id: req.user.user_id, - email: req.user.email, - tenant_id: body.tenant_id, - }, - secrets.JWT_SECRET!, - { expiresIn: "6h" } - ); - - reply.setCookie("token", token, { - path: "/", - httpOnly: true, - sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", - secure: process.env.NODE_ENV === "production", // lokal: false, prod: true - maxAge: 60 * 60 * 3, // 3 Stunden - }) - - return { token }; - }); - - server.get("/tenant/users", async (req, reply) => { - const { tenant_id } = req.params as { tenant_id: string }; - const authUser = req.user // kommt aus JWT (user_id + tenant_id) - - if (!authUser) { - return reply.code(401).send({ error: "Unauthorized" }) - } - - const { data, error } = await server.supabase - .from("auth_tenant_users") - .select(` - user_id, - auth_users!tenantusers_user_id_fkey ( id, email, created_at, auth_profiles(*))`) - .eq("tenant_id", authUser.tenant_id); - - if (error) { - console.log(error); - return reply.code(400).send({ error: error.message }); - } - - let correctedData = data.map(i => { - - - return { - id: i.user_id, - // @ts-ignore - email: i.auth_users.email, - // @ts-ignore - profile: i.auth_users.auth_profiles.find(x => x.tenant_id === authUser.tenant_id), - // @ts-ignore - full_name: i.auth_users.auth_profiles.find(x => x.tenant_id === authUser.tenant_id)?.full_name, } - }) + } + return { + message: "Server ist im MultiTenant-Modus – es werden alle verfügbaren Tenants geladen." + } + }) - return { tenant_id, users: correctedData }; - }); + + // ------------------------------------------------------------- + // SWITCH TENANT + // ------------------------------------------------------------- + server.post("/tenant/switch", async (req, reply) => { + try { + if (!req.user) { + return reply.code(401).send({ error: "Unauthorized" }) + } + + const { tenant_id } = req.body as { tenant_id: string } + if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" }) + + // prüfen ob der User zu diesem Tenant gehört + const membership = await server.db + .select() + .from(authTenantUsers) + .where( + eq(authTenantUsers.user_id, req.user.user_id) + ) + .where( + eq(authTenantUsers.tenant_id, Number(tenant_id)) + ) + + if (!membership.length) { + return reply.code(403).send({ error: "Not a member of this tenant" }) + } + + // JWT neu erzeugen + const token = jwt.sign( + { + user_id: req.user.user_id, + email: req.user.email, + tenant_id, + }, + secrets.JWT_SECRET!, + { expiresIn: "6h" } + ) + + reply.setCookie("token", token, { + path: "/", + httpOnly: true, + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 3, + }) + + return { token } + + } catch (err) { + console.error("TENANT SWITCH ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + + + // ------------------------------------------------------------- + // TENANT USERS (auth_users + auth_profiles) + // ------------------------------------------------------------- + server.get("/tenant/users", async (req, reply) => { + try { + const authUser = req.user + if (!authUser) return reply.code(401).send({ error: "Unauthorized" }) + + const tenantId = authUser.tenant_id + + // 1) auth_tenant_users → user_ids + const tenantUsers = await server.db + .select() + .from(authTenantUsers) + .where(eq(authTenantUsers.tenant_id, tenantId)) + + const userIds = tenantUsers.map(u => u.user_id) + + if (!userIds.length) { + return { tenant_id: tenantId, users: [] } + } + + // 2) auth_users laden + const users = await server.db + .select() + .from(authUsers) + .where(inArray(authUsers.id, userIds)) + + // 3) auth_profiles pro Tenant laden + const profiles = await server.db + .select() + .from(authProfiles) + .where(eq(authProfiles.tenant_id, tenantId)) + .where(inArray(authProfiles.user_id, userIds)) + + const combined = users.map(u => { + const profile = profiles.find(p => p.user_id === u.id) + return { + id: u.id, + email: u.email, + profile, + full_name: profile?.full_name ?? null + } + }) + + return { tenant_id: tenantId, users: combined } + + } catch (err) { + console.error("/tenant/users ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + + + // ------------------------------------------------------------- + // TENANT PROFILES + // ------------------------------------------------------------- server.get("/tenant/profiles", async (req, reply) => { - const { tenant_id } = req.params as { tenant_id: string }; - const authUser = req.user // kommt aus JWT (user_id + tenant_id) + try { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) - if (!authUser) { - return reply.code(401).send({ error: "Unauthorized" }) - } - - const { data, error } = await server.supabase - .from("auth_profiles") - .select() - .eq("tenant_id", authUser.tenant_id); - - if (error) { - console.log(error); - return reply.code(400).send({ error: error.message }); + const data = await server.db + .select() + .from(authProfiles) + .where(eq(authProfiles.tenant_id, tenantId)) + + return { data } + + } catch (err) { + console.error("/tenant/profiles ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) } + }) - return { data }; - }); + // ------------------------------------------------------------- + // UPDATE NUMBER RANGE + // ------------------------------------------------------------- server.put("/tenant/numberrange/:numberrange", async (req, reply) => { - if (!req.user) { - return reply.code(401).send({ error: "Unauthorized" }); + try { + const user = req.user + if (!user) return reply.code(401).send({ error: "Unauthorized" }) + + const { numberrange } = req.params as { numberrange: string } + const { numberRange } = req.body as { numberRange: any } + + if (!numberRange) { + return reply.code(400).send({ error: "numberRange required" }) + } + + const tenantId = Number(user.tenant_id) + + const currentTenantRows = await server.db + .select() + .from(tenants) + .where(eq(tenants.id, tenantId)) + + const current = currentTenantRows[0] + if (!current) return reply.code(404).send({ error: "Tenant not found" }) + + const updatedRanges = { + ...current.numberRanges, + [numberrange]: numberRange + } + + const updated = await server.db + .update(tenants) + .set({ numberRanges: updatedRanges }) + .where(eq(tenants.id, tenantId)) + .returning() + + return updated[0] + + } catch (err) { + console.error("/tenant/numberrange ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) } - const { numberrange } = req.params as { numberrange?: string } - - const body = req.body as { numberRange: object }; - console.log(body); - - if(!body.numberRange) { - return reply.code(400).send({ error: "numberRange required" }); - } - - const {data:currentTenantData,error:numberRangesError} = await server.supabase.from("tenants").select().eq("id", req.user.tenant_id).single() - - console.log(currentTenantData) - console.log(numberRangesError) + }) - let numberRanges = { - // @ts-ignore - ...currentTenantData.numberRanges - } - - // @ts-ignore - numberRanges[numberrange] = body.numberRange - - - console.log(numberRanges) - - const {data,error} = await server.supabase - .from("tenants") - .update({numberRanges: numberRanges}) - .eq('id',req.user.tenant_id) - .select() - - if(data && !error) { - return reply.send(data) - } - }); + // ------------------------------------------------------------- + // UPDATE TENANT OTHER FIELDS + // ------------------------------------------------------------- server.put("/tenant/other/:id", async (req, reply) => { - if (!req.user) { - return reply.code(401).send({ error: "Unauthorized" }); + try { + const user = req.user + if (!user) return reply.code(401).send({ error: "Unauthorized" }) + + const { id } = req.params as { id: string } + const { data } = req.body as { data: any } + + if (!data) return reply.code(400).send({ error: "data required" }) + + const updated = await server.db + .update(tenants) + .set(data) + .where(eq(tenants.id, Number(user.tenant_id))) + .returning() + + return updated[0] + + } catch (err) { + console.error("/tenant/other ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) } - const { id } = req.params as { id?: string } + }) - const body = req.body as { data: object }; - console.log(body); - - if(!body.data) { - return reply.code(400).send({ error: "data required" }); - } - - const {data:dataReturn,error} = await server.supabase - .from("tenants") - .update(body.data) - .eq('id',req.user.tenant_id) - .select() - - if(dataReturn && !error) { - return reply.send(dataReturn) - } - }); - -} \ No newline at end of file +}