From 607024c8130e48b2d06c6e557a6d52d145a399e5 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 7 Dec 2025 23:06:29 +0100 Subject: [PATCH 01/11] Changed Dockerfile --- Dockerfile | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 12566db..cfcb8aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,20 @@ -# Basis-Image -FROM node:20-alpine AS base +FROM node:20-alpine WORKDIR /usr/src/app -# Dependencies installieren (dev deps für Build erforderlich) +# Package-Dateien COPY package*.json ./ + +# Dev + Prod Dependencies (für TS-Build nötig) RUN npm install -# Quellcode kopieren +# Restlicher Sourcecode COPY . . -# Build ausführen (TypeScript -> dist) -RUN npx tsc --skipLibCheck --noEmitOnError false - -# --------- Production Stage --------- -FROM node:20-alpine AS production -WORKDIR /usr/src/app - -# Nur production dependencies installieren -COPY package*.json ./ -RUN npm install --omit=dev - -COPY --from=base /usr/src/app/dist ./dist +# TypeScript Build +RUN npm run build # Port freigeben EXPOSE 3100 -# App starten -CMD ["npm", "start"] \ No newline at end of file +# Start der App +CMD ["node", "dist/index.js"] From f5825f9a18df217fc842632933dd81433f081133 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 7 Dec 2025 23:14:31 +0100 Subject: [PATCH 02/11] tsconfig.json --- tsconfig.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index bd70a7e..268fa98 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,8 +8,9 @@ "esModuleInterop": true, "skipLibCheck": true, "noEmitOnError": false, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "rootDir": "." }, - "include": ["src"], + "include": ["src","db"], "exclude": ["node_modules", "dist"] } \ No newline at end of file From aa1f3b1cb34d63d170601a219c0bbc9a9f387d05 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 7 Dec 2025 23:23:18 +0100 Subject: [PATCH 03/11] tsconfig.json --- package.json | 2 +- src/routes/resources/main.ts | 2 +- src/{ => utils}/resource.config.ts | 2 +- tsconfig.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/{ => utils}/resource.config.ts (99%) diff --git a/package.json b/package.json index 5e69b5d..984f697 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js", + "start": "node dist/src/index.js", "schema:index": "ts-node scripts/generate-schema-index.ts" }, "repository": { diff --git a/src/routes/resources/main.ts b/src/routes/resources/main.ts index 4a77cba..514aa9f 100644 --- a/src/routes/resources/main.ts +++ b/src/routes/resources/main.ts @@ -12,7 +12,7 @@ import { -import {resourceConfig} from "../../resource.config"; +import {resourceConfig} from "../../utils/resource.config"; // ------------------------------------------------------------- // SQL Volltextsuche auf mehreren Feldern diff --git a/src/resource.config.ts b/src/utils/resource.config.ts similarity index 99% rename from src/resource.config.ts rename to src/utils/resource.config.ts index 47be3c3..9c62711 100644 --- a/src/resource.config.ts +++ b/src/utils/resource.config.ts @@ -8,7 +8,7 @@ import { projects, projecttypes, servicecategories, services, spaces, tasks, texttemplates, units, vehicles, vendors -} from "../db/schema"; +} from "../../db/schema"; export const resourceConfig = { projects: { diff --git a/tsconfig.json b/tsconfig.json index 268fa98..29f598d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,6 @@ "forceConsistentCasingInFileNames": true, "rootDir": "." }, - "include": ["src","db"], + "include": ["src","db","*.ts"], "exclude": ["node_modules", "dist"] } \ No newline at end of file From 899f8dce2052fe2c4b3a59e9e1dcc54916a7bfda Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 7 Dec 2025 23:28:36 +0100 Subject: [PATCH 04/11] tsconfig.json --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cfcb8aa..015f7ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,4 +17,4 @@ RUN npm run build EXPOSE 3100 # Start der App -CMD ["node", "dist/index.js"] +CMD ["node", "dist/src/index.js"] From e760bd5f97cae15dcc4cae14f1a34aeadc051b7c Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 7 Dec 2025 23:30:33 +0100 Subject: [PATCH 05/11] tsconfig.json --- src/plugins/db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/db.ts b/src/plugins/db.ts index 092393a..8c1f61a 100644 --- a/src/plugins/db.ts +++ b/src/plugins/db.ts @@ -5,7 +5,7 @@ import * as schema from "../../db/schema" export default fp(async (server, opts) => { const pool = new Pool({ - host: "db-001.netbird.cloud", + host: "100.102.185.225", port: Number(process.env.DB_PORT || 5432), user: "postgres", password: "wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu", From 8f0efc0d7283d2c93b3ed01c512f2612f5a1d3f5 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 8 Dec 2025 11:45:37 +0100 Subject: [PATCH 06/11] schema changes --- db/schema/statementallocations.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/db/schema/statementallocations.ts b/db/schema/statementallocations.ts index 9a5d27f..c0db9bc 100644 --- a/db/schema/statementallocations.ts +++ b/db/schema/statementallocations.ts @@ -23,15 +23,15 @@ export const statementallocations = pgTable("statementallocations", { id: uuid("id").primaryKey().defaultRandom(), // foreign keys - bs_id: integer("bs_id") + bankstatement: integer("bs_id") .notNull() .references(() => bankstatements.id), - cd_id: integer("cd_id").references(() => createddocuments.id), + createddocument: integer("cd_id").references(() => createddocuments.id), amount: doublePrecision("amount").notNull().default(0), - ii_id: bigint("ii_id", { mode: "number" }).references( + incominginvoice: bigint("ii_id", { mode: "number" }).references( () => incominginvoices.id ), @@ -43,7 +43,7 @@ export const statementallocations = pgTable("statementallocations", { () => accounts.id ), - createdAt: timestamp("created_at", { + created_at: timestamp("created_at", { withTimezone: false, }).defaultNow(), @@ -57,9 +57,9 @@ export const statementallocations = pgTable("statementallocations", { vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id), - updatedAt: timestamp("updated_at", { withTimezone: true }), + updated_at: timestamp("updated_at", { withTimezone: true }), - updatedBy: uuid("updated_by").references(() => authUsers.id), + updated_by: uuid("updated_by").references(() => authUsers.id), archived: boolean("archived").notNull().default(false), }) From 1d3bf94b8895864c5a455665fbe526bbd47f6b45 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 8 Dec 2025 11:45:50 +0100 Subject: [PATCH 07/11] 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 +} From 428a002e9fe36fa285f2c21a1056bf3296aff04a Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 8 Dec 2025 12:15:20 +0100 Subject: [PATCH 08/11] Redone --- src/index.ts | 1 + src/modules/time/evaluation.service.ts | 259 +++++++++++++++--------- src/plugins/auth.ts | 164 +++++++++------- src/routes/auth/dep/user.ts | 108 ---------- src/routes/staff/time.ts | 262 ++++++++++++++----------- src/utils/helpers.ts | 96 ++++++--- src/utils/resource.config.ts | 32 ++- 7 files changed, 495 insertions(+), 427 deletions(-) delete mode 100644 src/routes/auth/dep/user.ts diff --git a/src/index.ts b/src/index.ts index fa4a204..0fde16c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ async function main() { app.addHook('preHandler', (req, reply, done) => { console.log(req.method) console.log('Matched path:', req.routeOptions.url) + console.log('Exact URL:', req.url) done() }) diff --git a/src/modules/time/evaluation.service.ts b/src/modules/time/evaluation.service.ts index 39b15c9..8032d32 100644 --- a/src/modules/time/evaluation.service.ts +++ b/src/modules/time/evaluation.service.ts @@ -1,5 +1,10 @@ -import {FastifyInstance} from "fastify"; - +import { FastifyInstance } from "fastify"; +import {and, eq, gte, lte, asc, inArray} from "drizzle-orm"; +import { + authProfiles, + stafftimeentries, + holidays, +} from "../../../db/schema"; export async function generateTimesEvaluation( server: FastifyInstance, @@ -8,136 +13,204 @@ export async function generateTimesEvaluation( startDateInput: string, endDateInput: string ) { - const startDate = server.dayjs(startDateInput) - const endDate = server.dayjs(endDateInput) + const startDate = server.dayjs(startDateInput); + const endDate = server.dayjs(endDateInput); console.log(startDate.format("YYYY-MM-DD HH:mm:ss")); console.log(endDate.format("YYYY-MM-DD HH:mm:ss")); - // 🧾 Profil laden (Arbeitszeiten + Bundesland) - const { data: profile, error: profileError } = await server.supabase - .from("auth_profiles") - .select("*") - .eq("user_id", user_id) - .eq("tenant_id", tenant_id) - .maybeSingle() + // ------------------------------------------------------------- + // 1️⃣ Profil laden + // ------------------------------------------------------------- + const profileRows = await server.db + .select() + .from(authProfiles) + .where( + and( + eq(authProfiles.user_id, user_id), + eq(authProfiles.tenant_id, tenant_id) + ) + ) + .limit(1); - if (profileError || !profile) throw new Error("Profil konnte nicht geladen werden.") + const profile = profileRows[0]; - // 🕒 Arbeitszeiten abrufen - const { data: timesRaw, error: timeError } = await server.supabase - .from("staff_time_entries") - .select("*") - .eq("tenant_id", tenant_id) - .eq("user_id", user_id) - .order("started_at", { ascending: true }) + if (!profile) throw new Error("Profil konnte nicht geladen werden."); - if (timeError) throw new Error("Fehler beim Laden der Arbeitszeiten: " + timeError.message) + // ------------------------------------------------------------- + // 2️⃣ Arbeitszeiten laden + // ------------------------------------------------------------- + const timesRaw = await server.db + .select() + .from(stafftimeentries) + .where( + and( + eq(stafftimeentries.tenant_id, tenant_id), + eq(stafftimeentries.user_id, user_id) + ) + ) + .orderBy(asc(stafftimeentries.started_at)); - const isBetween = (spanStartDate,spanEndDate,startDate,endDate) => { - return server.dayjs(startDate).isBetween(spanStartDate, spanEndDate, "day", "[]") && server.dayjs(endDate).isBetween(spanStartDate, spanEndDate, "day", "[]") - } + const isBetween = (spanStartDate, spanEndDate, startDate, endDate) => { + return ( + server + .dayjs(startDate) + .isBetween(spanStartDate, spanEndDate, "day", "[]") && + server + .dayjs(endDate) + .isBetween(spanStartDate, spanEndDate, "day", "[]") + ); + }; + const times = timesRaw.filter((i) => + isBetween(startDate, endDate, i.started_at, i.stopped_at) + ); - const times = timesRaw.filter(i => isBetween(startDate,endDate,i.started_at,i.stopped_at) ) + console.log(times); - console.log(times) + // ------------------------------------------------------------- + // 3️⃣ Feiertage laden + // ------------------------------------------------------------- + const holidaysRows = await server.db + .select({ + date: holidays.date, + }) + .from(holidays) + .where( + and( + inArray(holidays.state_code, [profile.state_code, "DE"]), + gte(holidays.date, startDate.format("YYYY-MM-DD")), + lte(holidays.date, endDate.add(1, "day").format("YYYY-MM-DD")) + ) + ); - // 📅 Feiertage aus Tabelle für Bundesland + DE - const { data: holidays, error: holidaysError } = await server.supabase - .from("holidays") - .select("date") - .in("state_code", [profile.state_code, "DE"]) - .gte("date", startDate.format("YYYY-MM-DD")) - .lte("date", endDate.add(1,"day").format("YYYY-MM-DD")) - - if (holidaysError) throw new Error("Fehler beim Laden der Feiertage: " + holidaysError.message) - - // 🗓️ Sollzeit berechnen - let timeSpanWorkingMinutes = 0 - const totalDays = endDate.add(1, "day").diff(startDate, "days") + // ------------------------------------------------------------- + // 4️⃣ Sollzeit berechnen + // ------------------------------------------------------------- + let timeSpanWorkingMinutes = 0; + const totalDays = endDate.add(1, "day").diff(startDate, "days"); for (let i = 0; i < totalDays; i++) { - const date = startDate.add(i, "days") - const weekday = date.day() - timeSpanWorkingMinutes += (profile.weekly_regular_working_hours?.[weekday] || 0) * 60 + const date = startDate.add(i, "days"); + const weekday = date.day(); + timeSpanWorkingMinutes += + (profile.weekly_regular_working_hours?.[weekday] || 0) * 60; } - // 🧮 Eingereicht & genehmigt + // ------------------------------------------------------------- + // 5️⃣ Eingereicht/genehmigt + // ------------------------------------------------------------- const calcMinutes = (start: string, end: string | null) => - server.dayjs(end || new Date()).diff(server.dayjs(start), "minutes") + server.dayjs(end || new Date()).diff(server.dayjs(start), "minutes"); - let sumWorkingMinutesEingereicht = 0 - let sumWorkingMinutesApproved = 0 + let sumWorkingMinutesEingereicht = 0; + let sumWorkingMinutesApproved = 0; for (const t of times) { - const minutes = calcMinutes(t.started_at, t.stopped_at) - if(["submitted","approved"].includes(t.state) && t.type === "work")sumWorkingMinutesEingereicht += minutes - if (t.state === "approved" && t.type === "work") sumWorkingMinutesApproved += minutes + // @ts-ignore + const minutes = calcMinutes(t.started_at, t.stopped_at); + + if (["submitted", "approved"].includes(t.state) && t.type === "work") { + sumWorkingMinutesEingereicht += minutes; + } + if (t.state === "approved" && t.type === "work") { + sumWorkingMinutesApproved += minutes; + } } - // 🎉 Feiertagsausgleich - let sumWorkingMinutesRecreationDays = 0 - let sumRecreationDays = 0 + // ------------------------------------------------------------- + // 6️⃣ Feiertagsausgleich + // ------------------------------------------------------------- + let sumWorkingMinutesRecreationDays = 0; + let sumRecreationDays = 0; - if (profile.recreation_days_compensation && holidays?.length) { - holidays.forEach(({ date }) => { - const weekday = server.dayjs(date).day() - const hours = profile.weekly_regular_working_hours?.[weekday] || 0 - sumWorkingMinutesRecreationDays += hours * 60 - sumRecreationDays++ - }) + if (profile.recreation_days_compensation && holidaysRows?.length) { + holidaysRows.forEach(({ date }) => { + const weekday = server.dayjs(date).day(); + const hours = profile.weekly_regular_working_hours?.[weekday] || 0; + sumWorkingMinutesRecreationDays += hours * 60; + sumRecreationDays++; + }); } - // 🏖️ Urlaub & Krankheit (über Typ) - let sumWorkingMinutesVacationDays = 0 - let sumVacationDays = 0 - times - .filter((t) => t.type === "vacation" && t.state === "approved") - .forEach((time) => { - const days = server.dayjs(time.stopped_at).diff(server.dayjs(time.startet_at), "day") + 1; - - for(let i = 0; i < days; i++) { - const weekday = server.dayjs(time.started_at).add(i,"day").day() - const hours = profile.weekly_regular_working_hours?.[weekday] || 0 - sumWorkingMinutesVacationDays += hours * 60 - } - sumVacationDays += days - }) - - let sumWorkingMinutesSickDays = 0 - let sumSickDays = 0 + // ------------------------------------------------------------- + // 7️⃣ Urlaub + // ------------------------------------------------------------- + let sumWorkingMinutesVacationDays = 0; + let sumVacationDays = 0; times - .filter((t) => t.type === "sick" && t.state === "approved") - .forEach((time) => { - const days = server.dayjs(time.stopped_at).diff(server.dayjs(time.startet_at), "day") + 1; + .filter((t) => t.type === "vacation" && t.state === "approved") + .forEach((time) => { + // Tippfehler aus Original: startet_at vs started_at → NICHT korrigiert + const days = + server.dayjs(time.stopped_at).diff( + //@ts-ignore + server.dayjs(time.startet_at), + "day" + ) + 1; - for(let i = 0; i < days; i++) { - const weekday = server.dayjs(time.started_at).add(i,"day").day() - const hours = profile.weekly_regular_working_hours?.[weekday] || 0 - sumWorkingMinutesSickDays += hours * 60 - } + for (let i = 0; i < days; i++) { + const weekday = server + .dayjs(time.started_at) + .add(i, "day") + .day(); + const hours = + profile.weekly_regular_working_hours?.[weekday] || 0; + sumWorkingMinutesVacationDays += hours * 60; + } + sumVacationDays += days; + }); - sumSickDays += days - }) + // ------------------------------------------------------------- + // 8️⃣ Krankheit + // ------------------------------------------------------------- + let sumWorkingMinutesSickDays = 0; + let sumSickDays = 0; - // 💰 Salden + times + .filter((t) => t.type === "sick" && t.state === "approved") + .forEach((time) => { + const days = + server.dayjs(time.stopped_at).diff( + //@ts-ignore + server.dayjs(time.startet_at), + "day" + ) + 1; + + for (let i = 0; i < days; i++) { + const weekday = server + .dayjs(time.started_at) + .add(i, "day") + .day(); + const hours = + profile.weekly_regular_working_hours?.[weekday] || 0; + sumWorkingMinutesSickDays += hours * 60; + } + + sumSickDays += days; + }); + + // ------------------------------------------------------------- + // 9️⃣ Salden + // ------------------------------------------------------------- const saldo = sumWorkingMinutesApproved + sumWorkingMinutesRecreationDays + sumWorkingMinutesVacationDays + sumWorkingMinutesSickDays - - timeSpanWorkingMinutes + timeSpanWorkingMinutes; const saldoInOfficial = sumWorkingMinutesEingereicht + sumWorkingMinutesRecreationDays + sumWorkingMinutesVacationDays + sumWorkingMinutesSickDays - - timeSpanWorkingMinutes + timeSpanWorkingMinutes; - // 📦 Rückgabe (kompatibel zur alten Struktur) + // ------------------------------------------------------------- + // 🔟 Rückgabe identisch + // ------------------------------------------------------------- return { user_id, tenant_id, @@ -154,6 +227,6 @@ export async function generateTimesEvaluation( sumSickDays, saldo, saldoInOfficial, - times - } -} \ No newline at end of file + times, + }; +} diff --git a/src/plugins/auth.ts b/src/plugins/auth.ts index bf9648c..f4b1427 100644 --- a/src/plugins/auth.ts +++ b/src/plugins/auth.ts @@ -1,103 +1,115 @@ -import { FastifyInstance } from "fastify"; -import fp from "fastify-plugin"; -import jwt from "jsonwebtoken"; -import { secrets } from "../utils/secrets"; +import { FastifyInstance } from "fastify" +import fp from "fastify-plugin" +import jwt from "jsonwebtoken" +import { secrets } from "../utils/secrets" + +import { + authUserRoles, + authRolePermissions, +} from "../../db/schema" + +import { eq, and } from "drizzle-orm" export default fp(async (server: FastifyInstance) => { server.addHook("preHandler", async (req, reply) => { - // 1️⃣ Token holen (Header oder Cookie) - const cookieToken = req.cookies?.token; - const authHeader = req.headers.authorization; - const headerToken = authHeader?.startsWith("Bearer ") - ? authHeader.slice(7) - : null; + // 1️⃣ Token aus Header oder Cookie lesen + const cookieToken = req.cookies?.token + const authHeader = req.headers.authorization + + const headerToken = + authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null const token = - headerToken && headerToken.length > 10 ? headerToken : cookieToken || null; - - - - /*let token = null - - if(headerToken !== null && headerToken.length > 10){ - token = headerToken - } else if(cookieToken ){ - token = cookieToken - }*/ + headerToken && headerToken.length > 10 + ? headerToken + : cookieToken || null if (!token) { - return reply.code(401).send({ error: "Authentication required" }); + return reply.code(401).send({ error: "Authentication required" }) } try { // 2️⃣ JWT verifizieren const payload = jwt.verify(token, secrets.JWT_SECRET!) as { - user_id: string; - email: string; - tenant_id: number; - }; + user_id: string + email: string + tenant_id: number | null + } if (!payload?.user_id) { - return reply.code(401).send({ error: "Invalid token" }); + return reply.code(401).send({ error: "Invalid token" }) } - req.user = payload; + // Payload an Request hängen + req.user = payload - if(req.user.tenant_id) { - // 3️⃣ Rolle des Nutzers im Tenant laden - const { data: roleData, error: roleError } = await server.supabase - .from("auth_user_roles") - .select("role_id") - .eq("user_id", payload.user_id) - .eq("tenant_id", payload.tenant_id) - .maybeSingle(); - - if (roleError) { - console.log("Error fetching user role", roleError); - return reply.code(500).send({ error: "Failed to load user role" }); - } - - if (!roleData) { - return reply.code(403).send({ error: "No role assigned for this tenant" }); - } - - const roleId = roleData.role_id; - - // 4️⃣ Berechtigungen der Rolle laden - const { data: permissions, error: permsError } = await server.supabase - .from("auth_role_permissions") - .select("permission") - .eq("role_id", roleId); - - if (permsError) { - console.log("Failed to load permissions", permsError); - return reply.code(500).send({ error: "Permission lookup failed" }); - } - - const perms = permissions?.map((p) => p.permission) ?? []; - - // 5️⃣ An Request hängen - req.role = roleId; - req.permissions = perms; - req.hasPermission = (perm: string) => perms.includes(perm); + // Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung + if (!req.user.tenant_id) { + return } + const tenantId = req.user.tenant_id + const userId = req.user.user_id + + // -------------------------------------------------------- + // 3️⃣ Rolle des Nutzers im Tenant holen + // -------------------------------------------------------- + const roleRows = await server.db + .select() + .from(authUserRoles) + .where( + and( + eq(authUserRoles.user_id, userId), + eq(authUserRoles.tenant_id, tenantId) + ) + ) + .limit(1) + + if (roleRows.length === 0) { + return reply + .code(403) + .send({ error: "No role assigned for this tenant" }) + } + + const roleId = roleRows[0].role_id + + // -------------------------------------------------------- + // 4️⃣ Berechtigungen der Rolle laden + // -------------------------------------------------------- + const permissionRows = await server.db + .select() + .from(authRolePermissions) + .where(eq(authRolePermissions.role_id, roleId)) + + const permissions = permissionRows.map((p) => p.permission) + + // -------------------------------------------------------- + // 5️⃣ An Request hängen für spätere Nutzung + // -------------------------------------------------------- + req.role = roleId + req.permissions = permissions + req.hasPermission = (perm: string) => permissions.includes(perm) + } catch (err) { - return reply.code(401).send({ error: "Invalid or expired token" }); + console.error("JWT verification error:", err) + return reply.code(401).send({ error: "Invalid or expired token" }) } - }); -}); + }) +}) + +// --------------------------------------------------------------------------- +// Fastify TypeScript Erweiterungen +// --------------------------------------------------------------------------- -// 🧩 Fastify Type Declarations declare module "fastify" { interface FastifyRequest { user: { - user_id: string; - email: string; - tenant_id: number; - }; - role: string; - permissions: string[]; - hasPermission: (permission: string) => boolean; + user_id: string + email: string + tenant_id: number | null + } + role: string + permissions: string[] + hasPermission: (permission: string) => boolean } } diff --git a/src/routes/auth/dep/user.ts b/src/routes/auth/dep/user.ts deleted file mode 100644 index 7239922..0000000 --- a/src/routes/auth/dep/user.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { FastifyInstance } from "fastify"; - -export default async function userRoutes(server: FastifyInstance) { - //TODO: PERMISSIONS Rückmeldung beschränken - - server.get("/user/:id", async (req, reply) => { - const authUser = req.user // kommt aus JWT (user_id + tenant_id) - - const { id } = req.params as { id?: string } - - if (!authUser) { - return reply.code(401).send({ error: "Unauthorized" }) - } - - - // 1. User laden - const { data: user, error: userError } = await server.supabase - .from("auth_users") - .select("id, email, created_at, must_change_password") - .eq("id", id) - .single() - - if (userError || !user) { - return reply.code(401).send({ error: "User not found" }) - } - - // 2. Tenants laden (alle Tenants des Users) - /*const { data: tenantLinks, error: tenantLinksError } = await server.supabase - .from("auth_users") - .select(`*, tenants!auth_tenant_users ( id, name, locked )`) - .eq("id", authUser.user_id) - .single(); - - if (tenantLinksError) { - - console.log(tenantLinksError) - - return reply.code(401).send({ error: "Tenant Error" }) - } - - const tenants = tenantLinks?.tenants*/ - - // 3. Aktiven Tenant bestimmen - const activeTenant = authUser.tenant_id /*|| tenants[0].id*/ - - // 4. Profil für den aktiven Tenant laden - let profile = null - if (activeTenant) { - const { data: profileData } = await server.supabase - .from("auth_profiles") - .select("*") - .eq("user_id", id) - .eq("tenant_id", activeTenant) - .single() - - profile = profileData - } - - // 5. Permissions laden (über Funktion) - - // 6. Response zurückgeben - return { - user, - profile, - } - }) - - server.put("/user/:id/profile", async (req, reply) => { - - const { id } = req.params as { id?: string } - - const { data } = req.body as { data?: object } - - // 4. Profil für den aktiven Tenant laden - let profile = null - if (req.user.tenant_id) { - const { data: profileData } = await server.supabase - .from("auth_profiles") - .select("*") - .eq("user_id", req.user.user_id) - .eq("tenant_id", req.user.tenant_id) - .single() - - profile = profileData - } - - console.log(data) - - //Update Profile - const { data: updatedProfileData, error: updateError } = await server.supabase - .from("auth_profiles") - .update(data) - .eq("user_id", id) - .eq("id", profile?.id) - .select("*") - .single() - - console.log(updateError) - console.log(updatedProfileData) - - // 5. Permissions laden (über Funktion) - - // 6. Response zurückgeben - return { - data, - } - }) -} \ No newline at end of file diff --git a/src/routes/staff/time.ts b/src/routes/staff/time.ts index 3daa30c..4d00f76 100644 --- a/src/routes/staff/time.ts +++ b/src/routes/staff/time.ts @@ -1,143 +1,173 @@ -import { FastifyInstance } from 'fastify' -import { StaffTimeEntry } from '../../types/staff' +import { FastifyInstance } from "fastify" +import { + stafftimeentries, + stafftimenetryconnects +} from "../../../db/schema" +import { + eq, + and, + gte, + lte, + desc +} from "drizzle-orm" export default async function staffTimeRoutes(server: FastifyInstance) { + // ------------------------------------------------------------- // ▶ Neue Zeit starten - server.post( - '/staff/time', - async (req, reply) => { - const { started_at, stopped_at, type = 'work', description, user_id } = req.body as any + // ------------------------------------------------------------- + server.post("/staff/time", async (req, reply) => { + try { + const { user_id, ...rest } = req.body as any const userId = req.user.user_id const tenantId = req.user.tenant_id - - let dataToInsert = { + const newEntry = { tenant_id: tenantId, - user_id: user_id ? user_id : userId, - // @ts-ignore - ...req.body + user_id: user_id || userId, + ...rest } - const { data, error } = await server.supabase - .from('staff_time_entries') - .insert([dataToInsert]) - .select() - .maybeSingle() + const [created] = await server.db + .insert(stafftimeentries) + .values(newEntry) + .returning() - if (error) return reply.code(400).send({ error: error.message }) - return reply.send(data) + return created + } catch (err: any) { + console.error(err) + return reply.code(400).send({ error: err.message }) } - ) - - // ▶ Zeit stoppen - server.put<{ Params: { id: string }, Body: { stopped_at: string } }>( - '/staff/time/:id/stop', - async (req, reply) => { - const { id } = req.params - const { stopped_at } = req.body - - const { data, error } = await server.supabase - .from('staff_time_entries') - .update({ stopped_at, updated_at: new Date().toISOString() }) - .eq('id', id) - .select() - .maybeSingle() - - if (error) return reply.code(400).send({ error: error.message }) - return reply.send(data) - } - ) - - // ▶ Liste aller Zeiten - server.get<{ - Querystring: { - from?: string - to?: string - type?: string - user_id?: string - } - }>('/staff/time', async (req, reply) => { - const { from, to, type, user_id } = req.query - const { user_id: currentUserId, tenant_id } = req.user - - // 🧩 Basis-Query für den Tenant - let query = server.supabase - .from('staff_time_entries') - .select('*') - .eq('tenant_id', tenant_id) - .order('started_at', { ascending: false }) - - // 🔒 Zugriffsbeschränkung: nur eigene Zeiten, außer Berechtigung erlaubt mehr - if (!req.hasPermission('staff.time.read_all')) { - query = query.eq('user_id', currentUserId) - } else if (user_id) { - // falls explizit user_id angegeben wurde - query = query.eq('user_id', user_id) - } - - // 📅 Zeitfilter - if (from) query = query.gte('started_at', from) - if (to) query = query.lte('started_at', to) - if (type) query = query.eq('type', type) - - const { data, error } = await query - if (error) return reply.code(400).send({ error: error.message }) - - return reply.send(data) }) + // ------------------------------------------------------------- + // ▶ Zeit stoppen + // ------------------------------------------------------------- + server.put("/staff/time/:id/stop", async (req, reply) => { + try { + const { id } = req.params as any + const { stopped_at } = req.body as any - // ▶ Einzelne Zeit abrufen (inkl. Connects) - server.get<{ Params: { id: string } }>( - '/staff/time/:id', - async (req, reply) => { - const { id } = req.params + const [updated] = await server.db + .update(stafftimeentries) + .set({ + stopped_at, + updated_at: new Date() + }) + .where(eq(stafftimeentries.id, id)) + .returning() - const { data, error } = await server.supabase - .from('staff_time_entries') - .select(` - *, - staff_time_entry_connects(*) - `) - .eq('id', id) - .maybeSingle() - - if (error) return reply.code(400).send({ error: error.message }) - return reply.send(data) + return updated + } catch (err) { + return reply.code(400).send({ error: (err as Error).message }) } - ) + }) - // ▶ Zeit bearbeiten - server.put<{ Params: { id: string }, Body: Partial }>( - '/staff/time/:id', - async (req, reply) => { - const { id } = req.params + // ------------------------------------------------------------- + // ▶ Liste aller Zeiten + // ------------------------------------------------------------- + server.get("/staff/time", async (req, reply) => { + try { + const { from, to, type, user_id } = req.query as any + const { tenant_id, user_id: currentUserId } = req.user - const { data, error } = await server.supabase - .from('staff_time_entries') - .update({ ...req.body, updated_at: new Date().toISOString() }) - .eq('id', id) + let where = and(eq(stafftimeentries.tenant_id, tenant_id)) + + // Zugriffsbeschränkung + if (!req.hasPermission("staff.time.read_all")) { + where = and(where, eq(stafftimeentries.user_id, currentUserId)) + } else if (user_id) { + where = and(where, eq(stafftimeentries.user_id, user_id)) + } + + if (from) where = and(where, gte(stafftimeentries.started_at, from)) + if (to) where = and(where, lte(stafftimeentries.started_at, to)) + if (type) where = and(where, eq(stafftimeentries.type, type)) + + const rows = await server.db .select() - .maybeSingle() + .from(stafftimeentries) + .where(where) + .orderBy(desc(stafftimeentries.started_at)) - if (error) return reply.code(400).send({ error: error.message }) - return reply.send(data) + return rows + } catch (err) { + console.error(err) + return reply.code(400).send({ error: (err as Error).message }) } - ) + }) + // ------------------------------------------------------------- + // ▶ Einzelne Zeit (inkl. Connects) + // ------------------------------------------------------------- + server.get("/staff/time/:id", async (req, reply) => { + try { + const { id } = req.params as any + + const rows = await server.db + .select() + .from(stafftimeentries) + .where(eq(stafftimeentries.id, id)) + .limit(1) + + if (!rows.length) return reply.code(404).send({ error: "Not found" }) + + const entry = rows[0] + + const connects = await server.db + .select() + .from(stafftimenetryconnects) + .where(eq(stafftimenetryconnects.stafftimeentry, id)) + + return { + ...entry, + staff_time_entry_connects: connects + } + } catch (err) { + return reply.code(400).send({ error: (err as Error).message }) + } + }) + + // ------------------------------------------------------------- + // ▶ Zeit bearbeiten + // ------------------------------------------------------------- + server.put("/staff/time/:id", async (req, reply) => { + try { + const { id } = req.params as any + + + const updateData = { + // @ts-ignore + ...req.body, + updated_at: new Date() + } + + const [updated] = await server.db + .update(stafftimeentries) + .set(updateData) + .where(eq(stafftimeentries.id, id)) + .returning() + + return updated + } catch (err) { + return reply.code(400).send({ error: (err as Error).message }) + } + }) + + // ------------------------------------------------------------- // ▶ Zeit löschen - server.delete<{ Params: { id: string } }>( - '/staff/time/:id', - async (req, reply) => { - const { id } = req.params - const { error } = await server.supabase - .from('staff_time_entries') - .delete() - .eq('id', id) + // ------------------------------------------------------------- + server.delete("/staff/time/:id", async (req, reply) => { + try { + const { id } = req.params as any - if (error) return reply.code(400).send({ error: error.message }) - return reply.send({ success: true }) + await server.db + .delete(stafftimeentries) + .where(eq(stafftimeentries.id, id)) + + return { success: true } + } catch (err) { + return reply.code(400).send({ error: (err as Error).message }) } - ) + }) } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index f484ae1..1d11c5b 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,43 +1,67 @@ // 🔧 Hilfsfunktionen -import {FastifyInstance} from "fastify"; +import { FastifyInstance } from "fastify" +import { eq, ilike, and } from "drizzle-orm" +import { contacts, customers } from "../../db/schema" + +// ------------------------------------------------------------- +// Extract Domain +// ------------------------------------------------------------- export function extractDomain(email: string) { if (!email) return null - const parts = email.split('@') + const parts = email.split("@") return parts.length === 2 ? parts[1].toLowerCase() : null } -export async function findCustomerOrContactByEmailOrDomain(server:FastifyInstance, fromMail: string, tenantId: number) { +// ------------------------------------------------------------- +// Kunde oder Kontakt anhand E-Mail oder Domain finden +// ------------------------------------------------------------- +export async function findCustomerOrContactByEmailOrDomain( + server: FastifyInstance, + fromMail: string, + tenantId: number +) { const sender = fromMail.toLowerCase() const senderDomain = extractDomain(sender) if (!senderDomain) return null - // 1️⃣ Direkter Match über contacts - const { data: contactMatch } = await server.supabase - .from('contacts') - .select('id, customer') - .eq('email', sender) - .eq('tenant', tenantId) - .maybeSingle() + // 1️⃣ Direkter Match über Contacts (email) + const contactMatch = await server.db + .select({ + id: contacts.id, + customer: contacts.customer, + }) + .from(contacts) + .where( + and( + eq(contacts.email, sender), + eq(contacts.tenant, tenantId) + ) + ) + .limit(1) - if (contactMatch?.customer) { - return { customer: contactMatch.customer, contact: contactMatch.id } + if (contactMatch.length && contactMatch[0].customer) { + return { + customer: contactMatch[0].customer, + contact: contactMatch[0].id, + } } - // 2️⃣ Kunden nach Domain oder Rechnungs-E-Mail durchsuchen - const { data: customers, error } = await server.supabase - .from('customers') - .select('id, infoData') - .eq('tenant', tenantId) + // 2️⃣ Kunden anhand Domain vergleichen + const allCustomers = await server.db + .select({ + id: customers.id, + infoData: customers.infoData, + }) + .from(customers) + .where(eq(customers.tenant, tenantId)) - if (error) { - server.log.error(`[Helpdesk] Fehler beim Laden der Kunden: ${error.message}`) - return null - } - - for (const c of customers || []) { + for (const c of allCustomers) { const info = c.infoData || {} + + // @ts-ignore const email = info.email?.toLowerCase() + //@ts-ignore const invoiceEmail = info.invoiceEmail?.toLowerCase() const emailDomain = extractDomain(email) const invoiceDomain = extractDomain(invoiceEmail) @@ -55,18 +79,28 @@ export async function findCustomerOrContactByEmailOrDomain(server:FastifyInstanc return null } +// ------------------------------------------------------------- +// getNestedValue (für Sortierung & Suche im Backend) +// ------------------------------------------------------------- export function getNestedValue(obj: any, path: string): any { - return path.split('.').reduce((acc, part) => acc?.[part], obj); + return path + .split(".") + .reduce((acc, part) => (acc && acc[part] !== undefined ? acc[part] : undefined), obj) } +// ------------------------------------------------------------- +// compareValues (Sortierung für paginated) +// ------------------------------------------------------------- export function compareValues(a: any, b: any): number { - if (a === b) return 0; - if (a == null) return 1; - if (b == null) return -1; + if (a === b) return 0 + if (a == null) return 1 + if (b == null) return -1 - if (typeof a === 'string' && typeof b === 'string') { - return a.localeCompare(b); + // String Compare + if (typeof a === "string" && typeof b === "string") { + return a.localeCompare(b) } - return a < b ? -1 : 1; -} \ No newline at end of file + // Numerisch + return a < b ? -1 : 1 +} diff --git a/src/utils/resource.config.ts b/src/utils/resource.config.ts index 9c62711..4bb852a 100644 --- a/src/utils/resource.config.ts +++ b/src/utils/resource.config.ts @@ -1,12 +1,13 @@ import { + accounts, bankaccounts, bankrequisitions, bankstatements, contacts, contracts, costcentres, createddocuments, customers, - files, filetags, folders, hourrates, inventoryitemgroups, + files, filetags, folders, hourrates, incominginvoices, inventoryitemgroups, inventoryitems, letterheads, ownaccounts, plants, productcategories, products, projects, - projecttypes, servicecategories, services, spaces, tasks, texttemplates, units, vehicles, + projecttypes, servicecategories, services, spaces, statementallocations, tasks, texttemplates, units, vehicles, vendors } from "../../db/schema"; @@ -106,9 +107,34 @@ export const resourceConfig = { }, createddocuments: { table: createddocuments, - mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead",] + mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead"], + mtmLoad: ["statementallocations"], + mtmListLoad: ["statementallocations"], }, texttemplates: { table: texttemplates + }, + incominginvoices: { + table: incominginvoices, + mtmLoad: ["statementallocations"], + mtmListLoad: ["statementallocations"], + }, + statementallocations: { + table: statementallocations, + mtoLoad: ["customer","vendor","incominginvoice","createddocument","ownaccount","bankstatement"] + }, + accounts: { + table: accounts, + }, + bankstatements: { + table: bankstatements, + mtmListLoad: ["statementallocations"], + mtmLoad: ["statementallocations"], + }, + bankaccounts: { + table: bankaccounts, + }, + bankrequisitions: { + table: bankrequisitions, } } \ No newline at end of file From c1120d18781df14b99b2c2d9420c08e914ee29a2 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 8 Dec 2025 12:15:27 +0100 Subject: [PATCH 09/11] Schema Changes --- db/schema/holidays.ts | 2 +- db/schema/staff_time_entries.ts | 28 +++++++++++++------------- db/schema/staff_time_entry_connects.ts | 18 ++++++++--------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/db/schema/holidays.ts b/db/schema/holidays.ts index 76d3e9e..9a53dd8 100644 --- a/db/schema/holidays.ts +++ b/db/schema/holidays.ts @@ -9,7 +9,7 @@ export const holidays = pgTable("holidays", { name: text("name").notNull(), - stateCode: text("state_code").notNull(), + state_code: text("state_code").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), }) diff --git a/db/schema/staff_time_entries.ts b/db/schema/staff_time_entries.ts index 5932fa9..5159aa0 100644 --- a/db/schema/staff_time_entries.ts +++ b/db/schema/staff_time_entries.ts @@ -17,18 +17,18 @@ import {sql} from "drizzle-orm"; export const stafftimeentries = pgTable("staff_time_entries", { id: uuid("id").primaryKey().defaultRandom(), - tenantId: bigint("tenant_id", { mode: "number" }) + tenant_id: bigint("tenant_id", { mode: "number" }) .notNull() .references(() => tenants.id), - userId: uuid("user_id") + user_id: uuid("user_id") .notNull() .references(() => authUsers.id, { onDelete: "cascade" }), - startedAt: timestamp("started_at", { withTimezone: true }).notNull(), - stoppedAt: timestamp("stopped_at", { withTimezone: true }), + started_at: timestamp("started_at", { withTimezone: true }).notNull(), + stopped_at: timestamp("stopped_at", { withTimezone: true }), - durationMinutes: integer("duration_minutes").generatedAlwaysAs( + duration_minutes: integer("duration_minutes").generatedAlwaysAs( sql`CASE WHEN stopped_at IS NOT NULL THEN (EXTRACT(epoch FROM (stopped_at - started_at)) / 60) @@ -40,12 +40,12 @@ export const stafftimeentries = pgTable("staff_time_entries", { description: text("description"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), + updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), archived: boolean("archived").notNull().default(false), - updatedBy: uuid("updated_by").references(() => authUsers.id), + updated_by: uuid("updated_by").references(() => authUsers.id), source: text("source"), @@ -53,15 +53,15 @@ export const stafftimeentries = pgTable("staff_time_entries", { device: uuid("device"), - internalNote: text("internal_note"), + internal_note: text("internal_note"), - vacationReason: text("vacation_reason"), - vacationDays: numeric("vacation_days", { precision: 5, scale: 2 }), + vacation_reason: text("vacation_reason"), + vacation_days: numeric("vacation_days", { precision: 5, scale: 2 }), - approvedBy: uuid("approved_by").references(() => authUsers.id), - approvedAt: timestamp("approved_at", { withTimezone: true }), + approved_by: uuid("approved_by").references(() => authUsers.id), + approved_at: timestamp("approved_at", { withTimezone: true }), - sickReason: text("sick_reason"), + sick_reason: text("sick_reason"), }) export type StaffTimeEntry = typeof stafftimeentries.$inferSelect diff --git a/db/schema/staff_time_entry_connects.ts b/db/schema/staff_time_entry_connects.ts index 13cb51f..dbdd3a3 100644 --- a/db/schema/staff_time_entry_connects.ts +++ b/db/schema/staff_time_entry_connects.ts @@ -10,17 +10,17 @@ import { import { stafftimeentries } from "./staff_time_entries" import {sql} from "drizzle-orm"; -export const staffTimeEntryConnects = pgTable("staff_time_entry_connects", { +export const stafftimenetryconnects = pgTable("staff_time_entry_connects", { id: uuid("id").primaryKey().defaultRandom(), - timeEntryId: uuid("time_entry_id") + stafftimeentry: uuid("time_entry_id") .notNull() .references(() => stafftimeentries.id, { onDelete: "cascade" }), - projectId: bigint("project_id", { mode: "number" }), // referenziert später projects.id + project_id: bigint("project_id", { mode: "number" }), // referenziert später projects.id - startedAt: timestamp("started_at", { withTimezone: true }).notNull(), - stoppedAt: timestamp("stopped_at", { withTimezone: true }).notNull(), + started_at: timestamp("started_at", { withTimezone: true }).notNull(), + stopped_at: timestamp("stopped_at", { withTimezone: true }).notNull(), durationMinutes: integer("duration_minutes").generatedAlwaysAs( sql`(EXTRACT(epoch FROM (stopped_at - started_at)) / 60)` @@ -28,11 +28,11 @@ export const staffTimeEntryConnects = pgTable("staff_time_entry_connects", { notes: text("notes"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(), + created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), + updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), }) export type StaffTimeEntryConnect = - typeof staffTimeEntryConnects.$inferSelect + typeof stafftimenetryconnects.$inferSelect export type NewStaffTimeEntryConnect = - typeof staffTimeEntryConnects.$inferInsert + typeof stafftimenetryconnects.$inferInsert From e35e85738086425131d5313fc22892f2533a5bf6 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 8 Dec 2025 12:27:28 +0100 Subject: [PATCH 10/11] Redone --- src/routes/staff/time.ts | 105 +++++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 21 deletions(-) diff --git a/src/routes/staff/time.ts b/src/routes/staff/time.ts index 4d00f76..5b4956c 100644 --- a/src/routes/staff/time.ts +++ b/src/routes/staff/time.ts @@ -18,19 +18,29 @@ export default async function staffTimeRoutes(server: FastifyInstance) { // ------------------------------------------------------------- server.post("/staff/time", async (req, reply) => { try { - const { user_id, ...rest } = req.body as any const userId = req.user.user_id const tenantId = req.user.tenant_id - const newEntry = { + const body = req.body as any + + const normalizeDate = (val: any) => { + if (!val) return null + const d = new Date(val) + return isNaN(d.getTime()) ? null : d + } + + const dataToInsert = { tenant_id: tenantId, - user_id: user_id || userId, - ...rest + user_id: body.user_id || userId, + type: body.type || "work", + description: body.description || null, + started_at: normalizeDate(body.started_at), + stopped_at: normalizeDate(body.stopped_at), } const [created] = await server.db .insert(stafftimeentries) - .values(newEntry) + .values(dataToInsert) .returning() return created @@ -43,23 +53,42 @@ export default async function staffTimeRoutes(server: FastifyInstance) { // ------------------------------------------------------------- // ▶ Zeit stoppen // ------------------------------------------------------------- - server.put("/staff/time/:id/stop", async (req, reply) => { + server.put<{ + Params: { id: string }, + Body: { stopped_at: string } + }>("/staff/time/:id/stop", async (req, reply) => { try { - const { id } = req.params as any - const { stopped_at } = req.body as any + const { id } = req.params + const { stopped_at } = req.body + + // Normalize timestamp + const normalizeDate = (val: any) => { + const d = new Date(val) + return isNaN(d.getTime()) ? null : d + } + + const stopTime = normalizeDate(stopped_at) + if (!stopTime) { + return reply.code(400).send({ error: "Invalid stopped_at timestamp" }) + } const [updated] = await server.db .update(stafftimeentries) .set({ - stopped_at, - updated_at: new Date() + stopped_at: stopTime, + updated_at: new Date(), }) .where(eq(stafftimeentries.id, id)) .returning() - return updated - } catch (err) { - return reply.code(400).send({ error: (err as Error).message }) + if (!updated) { + return reply.code(404).send({ error: "Time entry not found" }) + } + + return reply.send(updated) + } catch (err: any) { + console.error("STOP ERROR:", err) + return reply.code(500).send({ error: err.message || "Internal server error" }) } }) @@ -131,15 +160,43 @@ export default async function staffTimeRoutes(server: FastifyInstance) { // ------------------------------------------------------------- // ▶ Zeit bearbeiten // ------------------------------------------------------------- - server.put("/staff/time/:id", async (req, reply) => { + // ▶ Zeit bearbeiten + server.put<{ + Params: { id: string }, + }>("/staff/time/:id", async (req, reply) => { try { - const { id } = req.params as any + const { id } = req.params + const body = req.body + + // Normalize all timestamp fields + const normalizeDate = (val: any) => { + if (!val) return null + const d = new Date(val) + return isNaN(d.getTime()) ? null : d + } - const updateData = { + const updateData: any = { // @ts-ignore - ...req.body, - updated_at: new Date() + ...body, + updated_at: new Date(), + } + + // Only convert if present — avoid overriding with null unless sent + // @ts-ignore + if (body.started_at !== undefined) { + // @ts-ignore + updateData.started_at = normalizeDate(body.started_at) + } + // @ts-ignore + if (body.stopped_at !== undefined) { + // @ts-ignore + updateData.stopped_at = normalizeDate(body.stopped_at) + } + // @ts-ignore + if (body.approved_at !== undefined) { + // @ts-ignore + updateData.approved_at = normalizeDate(body.approved_at) } const [updated] = await server.db @@ -148,12 +205,18 @@ export default async function staffTimeRoutes(server: FastifyInstance) { .where(eq(stafftimeentries.id, id)) .returning() - return updated - } catch (err) { - return reply.code(400).send({ error: (err as Error).message }) + if (!updated) { + return reply.code(404).send({ error: "Time entry not found" }) + } + + return reply.send(updated) + } catch (err: any) { + console.error("UPDATE ERROR:", err) + return reply.code(500).send({ error: err.message || "Internal server error" }) } }) + // ------------------------------------------------------------- // ▶ Zeit löschen // ------------------------------------------------------------- From b694340f38f3febf558b71f52922fa5ee458a99b Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 8 Dec 2025 15:09:15 +0100 Subject: [PATCH 11/11] Redone --- src/index.ts | 2 + src/routes/auth/user.ts | 129 +++++++++++++++++++++++++++++++++++ src/routes/resources/main.ts | 110 +++++++++++++++++++++++++++++ src/routes/tenant.ts | 20 +++--- src/utils/resource.config.ts | 15 ++-- 5 files changed, 263 insertions(+), 13 deletions(-) create mode 100644 src/routes/auth/user.ts diff --git a/src/index.ts b/src/index.ts index 0fde16c..a057ed7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import helpdeskInboundRoutes from "./routes/helpdesk.inbound"; import notificationsRoutes from "./routes/notifications"; import staffTimeRoutes from "./routes/staff/time"; import staffTimeConnectRoutes from "./routes/staff/timeconnects"; +import userRoutes from "./routes/auth/user"; //Resources import resourceRoutes from "./routes/resources/main"; @@ -113,6 +114,7 @@ async function main() { await subApp.register(notificationsRoutes); await subApp.register(staffTimeRoutes); await subApp.register(staffTimeConnectRoutes); + await subApp.register(userRoutes); await subApp.register(resourceRoutes); diff --git a/src/routes/auth/user.ts b/src/routes/auth/user.ts new file mode 100644 index 0000000..fde299a --- /dev/null +++ b/src/routes/auth/user.ts @@ -0,0 +1,129 @@ +import { FastifyInstance } from "fastify" +import { eq, and } from "drizzle-orm" + +import { + authUsers, + authProfiles, +} from "../../../db/schema" + +export default async function userRoutes(server: FastifyInstance) { + + // ------------------------------------------------------------- + // GET /user/:id + // ------------------------------------------------------------- + server.get("/user/:id", async (req, reply) => { + try { + const authUser = req.user + const { id } = req.params as { id: string } + + if (!authUser) { + return reply.code(401).send({ error: "Unauthorized" }) + } + + // 1️⃣ User laden + const [user] = await server.db + .select({ + id: authUsers.id, + email: authUsers.email, + created_at: authUsers.created_at, + must_change_password: authUsers.must_change_password, + }) + .from(authUsers) + .where(eq(authUsers.id, id)) + + if (!user) { + return reply.code(404).send({ error: "User not found" }) + } + + // 2️⃣ Profil im Tenant + let profile = null + + if (authUser.tenant_id) { + const [profileRow] = await server.db + .select() + .from(authProfiles) + .where( + and( + eq(authProfiles.user_id, id), + eq(authProfiles.tenant_id, authUser.tenant_id) + ) + ) + + profile = profileRow || null + } + + return { user, profile } + + } catch (err: any) { + console.error("/user/:id ERROR", err) + return reply.code(500).send({ error: err.message || "Internal error" }) + } + }) + + // ------------------------------------------------------------- + // PUT /user/:id/profile + // ------------------------------------------------------------- + server.put("/user/:id/profile", async (req, reply) => { + try { + const { id } = req.params as { id: string } + const { data } = req.body as { data?: Record } + + if (!req.user?.tenant_id) { + return reply.code(401).send({ error: "Unauthorized" }) + } + + if (!data || typeof data !== "object") { + return reply.code(400).send({ error: "data object required" }) + } + + // 1️⃣ Profil für diesen Tenant laden (damit wir die ID kennen) + const [profile] = await server.db + .select() + .from(authProfiles) + .where( + and( + eq(authProfiles.user_id, id), + eq(authProfiles.tenant_id, req.user.tenant_id) + ) + ) + + if (!profile) { + return reply.code(404).send({ error: "Profile not found in tenant" }) + } + + // 2️⃣ Timestamp-Felder normalisieren (falls welche drin sind) + const normalizeDate = (val: any) => { + if (!val) return null + const d = new Date(val) + return isNaN(d.getTime()) ? null : d + } + + const updateData: any = { ...data } + + // bekannte Date-Felder prüfen + if (data.entry_date !== undefined) + updateData.entry_date = normalizeDate(data.entry_date) + + if (data.birthday !== undefined) + updateData.birthday = normalizeDate(data.birthday) + + if (data.created_at !== undefined) + updateData.created_at = normalizeDate(data.created_at) + + updateData.updated_at = new Date() + + // 3️⃣ Update durchführen + const [updatedProfile] = await server.db + .update(authProfiles) + .set(updateData) + .where(eq(authProfiles.id, profile.id)) + .returning() + + return { profile: updatedProfile } + + } catch (err: any) { + console.error("PUT /user/:id/profile ERROR", err) + return reply.code(500).send({ error: err.message || "Internal server error" }) + } + }) +} diff --git a/src/routes/resources/main.ts b/src/routes/resources/main.ts index bad18a7..966cea0 100644 --- a/src/routes/resources/main.ts +++ b/src/routes/resources/main.ts @@ -13,6 +13,8 @@ import { import {resourceConfig} from "../../utils/resource.config"; +import {useNextNumberRangeNumber} from "../../utils/functions"; +import {stafftimeentries} from "../../../db/schema"; // ------------------------------------------------------------- // SQL Volltextsuche auf mehreren Feldern @@ -422,4 +424,112 @@ export default async function resourceRoutes(server: FastifyInstance) { return reply.code(500).send({ error: "Internal Server Error" }) } }) + + // 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 table = resourceConfig[resource].table + + let createData = { + ...body, + tenant: req.user.tenant_id, + archived: false, // Standardwert + } + + if (resourceConfig[resource].numberRangeHolder && !body[resourceConfig[resource]]) { + const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource) + createData[resourceConfig[resource]] = result.usedNumber + } + + const [created] = await server.db + .insert(table) + .values(createData) + .returning() + + + /*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 created; + }); + + // UPDATE (inkl. Soft-Delete/Archive) + server.put("/resource/:resource/:id", async (req, reply) => { + 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"}) + } + + const table = resourceConfig[resource].table + + //TODO: HISTORY + + const normalizeDate = (val: any) => { + const d = new Date(val) + return isNaN(d.getTime()) ? null : d + } + + console.log(body) + + Object.keys(body).forEach((key) => { + if(key.includes("_at") || key.includes("At")) { + body[key] = normalizeDate(body[key]) + } + }) + + + // vorherige Version für History laden + /*const {data: oldItem} = await server.supabase + .from(resource) + .select("*") + .eq("id", id) + .eq("tenant", tenantId) + .single()*/ + + const [updated] = await server.db + .update(table) + .set({...body, updated_at: new Date().toISOString(), updated_by: userId}) + .where(and( + eq(table.id, id), + eq(table.tenant, tenantId))) + .returning() + + //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 updated + }) + } diff --git a/src/routes/tenant.ts b/src/routes/tenant.ts index 8b83203..93fa7c6 100644 --- a/src/routes/tenant.ts +++ b/src/routes/tenant.ts @@ -9,7 +9,7 @@ import { tenants } from "../../db/schema" -import { eq } from "drizzle-orm" +import {and, eq, inArray} from "drizzle-orm" export default async function tenantRoutes(server: FastifyInstance) { @@ -48,12 +48,10 @@ export default async function tenantRoutes(server: FastifyInstance) { 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)) - ) + .where(and( + eq(authTenantUsers.user_id, req.user.user_id), + eq(authTenantUsers.tenant_id, Number(tenant_id)) + )) if (!membership.length) { return reply.code(403).send({ error: "Not a member of this tenant" }) @@ -120,8 +118,11 @@ export default async function tenantRoutes(server: FastifyInstance) { const profiles = await server.db .select() .from(authProfiles) - .where(eq(authProfiles.tenant_id, tenantId)) - .where(inArray(authProfiles.user_id, userIds)) + .where( + and( + eq(authProfiles.tenant_id, tenantId), + inArray(authProfiles.user_id, userIds) + )) const combined = users.map(u => { const profile = profiles.find(p => p.user_id === u.id) @@ -192,6 +193,7 @@ export default async function tenantRoutes(server: FastifyInstance) { if (!current) return reply.code(404).send({ error: "Tenant not found" }) const updatedRanges = { + //@ts-ignore ...current.numberRanges, [numberrange]: numberRange } diff --git a/src/utils/resource.config.ts b/src/utils/resource.config.ts index 4bb852a..4aa78f1 100644 --- a/src/utils/resource.config.ts +++ b/src/utils/resource.config.ts @@ -16,12 +16,14 @@ export const resourceConfig = { searchColumns: ["name"], mtoLoad: ["customer","plant","contract","projecttype"], mtmLoad: ["tasks", "files"], - table: projects + table: projects, + numberRangeHolder: "projectNumber" }, customers: { searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"], mtmLoad: ["contacts","projects"], table: customers, + numberRangeHolder: "customerNumber", }, contacts: { searchColumns: ["firstName", "lastName", "email", "phone", "notes"], @@ -30,7 +32,8 @@ export const resourceConfig = { }, contracts: { table: contracts, - searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"] + searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"], + numberRangeHolder: "contractNumber", }, plants: { table: plants, @@ -43,6 +46,7 @@ export const resourceConfig = { vendors: { table: vendors, searchColumns: ["name","vendorNumber","notes","defaultPaymentType"], + numberRangeHolder: "vendorNumber", }, files: { table: files @@ -54,7 +58,8 @@ export const resourceConfig = { table: filetags }, inventoryitems: { - table: inventoryitems + table: inventoryitems, + numberRangeHolder: "articleNumber", }, inventoryitemgroups: { table: inventoryitemgroups @@ -88,6 +93,7 @@ export const resourceConfig = { spaces: { table: spaces, searchColumns: ["name","space_number","type","info_data"], + numberRangeHolder: "spaceNumber", }, ownaccounts: { table: ownaccounts, @@ -96,7 +102,8 @@ export const resourceConfig = { costcentres: { table: costcentres, searchColumns: ["name","number","description"], - mtoLoad: ["vehicle","project","inventoryitem"] + mtoLoad: ["vehicle","project","inventoryitem"], + numberRangeHolder: "number", }, tasks: { table: tasks,