From 97a095b4229d6805b45814d126eed47aaedf2794 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 31 Aug 2025 18:29:29 +0200 Subject: [PATCH] Changes --- TODO.md | 32 ++ src/index.ts | 65 ++++ src/plugins/auth.ts | 54 +++ src/plugins/cors.ts | 16 + src/plugins/supabase.ts | 19 + src/plugins/swagger.ts | 29 ++ src/plugins/tenant.ts | 44 +++ src/routes/admin.ts | 94 +++++ src/routes/auth-authenticated.ts | 76 ++++ src/routes/auth.ts | 221 ++++++++++++ src/routes/health.ts | 14 + src/routes/history.ts | 161 +++++++++ src/routes/me.ts | 80 +++++ src/routes/profiles.ts | 57 +++ src/routes/resources.ts | 580 +++++++++++++++++++++++++++++++ src/routes/tenant.ts | 64 ++++ src/utils/diff.ts | 103 ++++++ src/utils/diffTranslations.ts | 165 +++++++++ src/utils/history.ts | 69 ++++ src/utils/mailer.ts | 32 ++ src/utils/password.ts | 15 + 21 files changed, 1990 insertions(+) create mode 100644 TODO.md create mode 100644 src/index.ts create mode 100644 src/plugins/auth.ts create mode 100644 src/plugins/cors.ts create mode 100644 src/plugins/supabase.ts create mode 100644 src/plugins/swagger.ts create mode 100644 src/plugins/tenant.ts create mode 100644 src/routes/admin.ts create mode 100644 src/routes/auth-authenticated.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/health.ts create mode 100644 src/routes/history.ts create mode 100644 src/routes/me.ts create mode 100644 src/routes/profiles.ts create mode 100644 src/routes/resources.ts create mode 100644 src/routes/tenant.ts create mode 100644 src/utils/diff.ts create mode 100644 src/utils/diffTranslations.ts create mode 100644 src/utils/history.ts create mode 100644 src/utils/mailer.ts create mode 100644 src/utils/password.ts diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..da98877 --- /dev/null +++ b/TODO.md @@ -0,0 +1,32 @@ +# Projekt To-Do Liste + +## ✅ Erledigt +- JWT-basierte Authentifizierung mit Cookie +- Prefix für Auth-Tabellen (`auth_users`, `auth_roles`, …) +- `/me` liefert User + Rechte (via `auth_get_user_permissions`) +- Basis-Seed für Standardrollen + Rechte eingespielt +- Auth Middleware im Frontend korrigiert (Login-Redirects) +- Nuxt API Plugin unterstützt JWT im Header +- Login-Seite an Nuxt UI Pro (v2) anpassen +- `usePermission()` Composable im Frontend vorbereitet + +--- + +## 🔄 Offene Punkte + +### Backend +- [ ] `/me` erweitern: Rollen + deren Rechte strukturiert zurückgeben (`{ role: "manager", permissions: [...] }`) +- [ ] Loading Flag im Auth-Flow berücksichtigen (damit `me` nicht doppelt feuert) +- [ ] Gemeinsames Schema für Entities (Backend stellt per Endpoint bereit) +- [ ] Soft Delete vereinheitlichen (`archived = true` statt DELETE) +- [ ] Swagger-Doku verbessern (Schemas, Beispiele) + +### Frontend +- [ ] Loading Flag in Middleware/Store einbauen +- [ ] Einheitliches Laden des Schemas beim Start +- [ ] Pinia-Store für Auth/User/Tenant konsolidieren +- [ ] Composable `usePermission(key)` implementieren, um Rechte einfach im Template zu prüfen +- [ ] Entity-Seiten schrittweise auf API-Routen umstellen +- [ ] Page Guards für Routen einbauen (z. B. `/projects/create` nur bei `projects-create`) + +--- \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c95ded7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,65 @@ +import Fastify from "fastify"; +import swaggerPlugin from "./plugins/swagger" +import supabasePlugin from "./plugins/supabase"; +import healthRoutes from "./routes/health"; +import meRoutes from "./routes/me"; +import tenantRoutes from "./routes/tenant"; +import tenantPlugin from "./plugins/tenant"; +import authRoutes from "./routes/auth"; +import authRoutesAuthenticated from "./routes/auth-authenticated"; +import authPlugin from "./plugins/auth"; +import adminRoutes from "./routes/admin"; +import corsPlugin from "./plugins/cors"; +import resourceRoutes from "./routes/resources"; +import fastifyCookie from "@fastify/cookie"; +import historyRoutes from "./routes/history"; + +import {sendMail} from "./utils/mailer"; + +async function main() { + const app = Fastify({ logger: true }); + + /*app.addHook("onRequest", (req, reply, done) => { + console.log("Incoming:", req.method, req.url, "Headers:", req.headers) + done() + })*/ + + // Plugins Global verfügbar + await app.register(swaggerPlugin); + await app.register(corsPlugin); + await app.register(supabasePlugin); + await app.register(tenantPlugin); + app.register(fastifyCookie, { + secret: process.env.COOKIE_SECRET || "supersecret", // optional, für signierte Cookies + }) + // Öffentliche Routes + await app.register(authRoutes); + await app.register(healthRoutes); + + + + //Geschützte Routes + + await app.register(async (subApp) => { + await subApp.register(authPlugin); + await subApp.register(authRoutesAuthenticated); + await subApp.register(meRoutes); + await subApp.register(tenantRoutes); + await subApp.register(adminRoutes); + await subApp.register(resourceRoutes); + await subApp.register(historyRoutes); + + },{prefix: "/api"}) + + + // Start + try { + await app.listen({ port: 3100 }); + console.log("🚀 Server läuft auf http://localhost:3100"); + } catch (err) { + app.log.error(err); + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/src/plugins/auth.ts b/src/plugins/auth.ts new file mode 100644 index 0000000..68cd018 --- /dev/null +++ b/src/plugins/auth.ts @@ -0,0 +1,54 @@ +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import jwt from "jsonwebtoken"; + +export default fp(async (server: FastifyInstance) => { + server.addHook("preHandler", async (req, reply) => { + + try { + // 1) Token aus Cookie lesen + const cookieToken = req.cookies?.token + + // 2) Token aus Header lesen (falls Cookie nicht da ist) + const authHeader = req.headers.authorization + const headerToken = authHeader?.startsWith("Bearer ") + ? authHeader.slice(7) + : null + + let token = null + + if(headerToken !== null && headerToken.length > 10){ + token = headerToken + } else if(cookieToken ){ + token = cookieToken + } + + if (!token) { + return // keine Exception → Route darf z. B. public sein + } + + + const payload = jwt.verify(token, process.env.JWT_SECRET!) as { + user_id: string; + email: string; + tenant_id?: string; + role?: string; + }; + + (req as any).user = payload; + } catch (err) { + return reply.code(401).send({ error: "Invalid or expired token" }); + } + }); +}); + +declare module "fastify" { + interface FastifyRequest { + user?: { + user_id: string; + email: string; + tenant_id?: string; + role?: string; + }; + } +} \ No newline at end of file diff --git a/src/plugins/cors.ts b/src/plugins/cors.ts new file mode 100644 index 0000000..6d806bd --- /dev/null +++ b/src/plugins/cors.ts @@ -0,0 +1,16 @@ +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import cors from "@fastify/cors"; + +export default fp(async (server: FastifyInstance) => { + await server.register(cors, { + origin: [ + "http://localhost:3000", // dein Nuxt-Frontend + "http://127.0.0.1:3000", // dein Nuxt-Frontend + ], + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "Context"], + exposedHeaders: ["Authorization"], // optional, falls du ihn auch auslesen willst + credentials: true, // wichtig, falls du Cookies nutzt + }); +}); \ No newline at end of file diff --git a/src/plugins/supabase.ts b/src/plugins/supabase.ts new file mode 100644 index 0000000..66172df --- /dev/null +++ b/src/plugins/supabase.ts @@ -0,0 +1,19 @@ +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import { createClient, SupabaseClient } from "@supabase/supabase-js"; + +export default fp(async (server: FastifyInstance) => { + const supabaseUrl = process.env.SUPABASE_URL || "https://uwppvcxflrcsibuzsbil.supabase.co"; + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV3cHB2Y3hmbHJjc2lidXpzYmlsIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTcwMDkzODE5NCwiZXhwIjoyMDE2NTE0MTk0fQ.6hOkD1J8XBkVJUm-swv0ngLQ74xrEYr28EEbo0rUrts"; + + const supabase: SupabaseClient = createClient(supabaseUrl, supabaseServiceKey); + + // Fastify um supabase erweitern + server.decorate("supabase", supabase); +}); + +declare module "fastify" { + interface FastifyInstance { + supabase: SupabaseClient; + } +} \ No newline at end of file diff --git a/src/plugins/swagger.ts b/src/plugins/swagger.ts new file mode 100644 index 0000000..7bd32d9 --- /dev/null +++ b/src/plugins/swagger.ts @@ -0,0 +1,29 @@ +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import swagger from "@fastify/swagger"; +import swaggerUi from "@fastify/swagger-ui"; + +export default fp(async (server: FastifyInstance) => { + await server.register(swagger, { + mode: "dynamic", // wichtig: generiert echtes OpenAPI JSON + openapi: { + info: { + title: "Multi-Tenant API", + description: "API Dokumentation für dein Backend", + version: "1.0.0", + }, + servers: [{ url: "http://localhost:3000" }], + }, + }); + + await server.register(swaggerUi, { + routePrefix: "/docs", // UI erreichbar unter http://localhost:3000/docs + swagger: { + info: { + title: "Multi-Tenant API", + version: "1.0.0", + }, + }, + exposeRoute: true, + }); +}); \ No newline at end of file diff --git a/src/plugins/tenant.ts b/src/plugins/tenant.ts new file mode 100644 index 0000000..d77fab2 --- /dev/null +++ b/src/plugins/tenant.ts @@ -0,0 +1,44 @@ +import { FastifyInstance, FastifyRequest } from "fastify"; +import fp from "fastify-plugin"; + +export default fp(async (server: FastifyInstance) => { + server.addHook("preHandler", async (req, reply) => { + const host = req.headers.host?.split(":")[0]; // Domain ohne Port + if (!host) { + reply.code(400).send({ error: "Missing host header" }); + return; + } + + console.log(host) + + // Tenant aus DB laden + const { data: tenant } = await server.supabase + .from("tenants") + .select("*") + .eq("portalDomain", host) + .single(); + + + if(!tenant) { + // Multi Tenant Mode + (req as any).tenant = null; + }else { + // Tenant ins Request-Objekt hängen + (req as any).tenant = tenant; + } + + }); +}); + +// Typ-Erweiterung +declare module "fastify" { + interface FastifyRequest { + tenant?: { + id: string; + name: string; + domain?: string; + subdomain?: string; + settings?: Record; + }; + } +} \ No newline at end of file diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..ebf3e49 --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,94 @@ +import { FastifyInstance } from "fastify"; + +export default async function adminRoutes(server: FastifyInstance) { + server.post("/admin/add-user-to-tenant", async (req, reply) => { + const body = req.body as { + user_id: string; + tenant_id: string; + role?: string; + mode?: "single" | "multi"; + }; + + if (!body.user_id || !body.tenant_id) { + return reply.code(400).send({ error: "user_id and tenant_id required" }); + } + + // Default: "multi" + const mode = body.mode ?? "multi"; + + if (mode === "single") { + // Erst alle alten Verknüpfungen löschen + await server.supabase + .from("auth_tenant_users") + .delete() + .eq("user_id", body.user_id); + } + + const { error } = await server.supabase + .from("auth_tenant_users") + .insert({ + tenant_id: body.tenant_id, + user_id: body.user_id, + role: body.role ?? "member", + }); + + if (error) { + return reply.code(400).send({ error: error.message }); + } + + // Neuen Eintrag setzen + + + return { success: true, mode }; + }); + + /** + * Alle Tenants eines Users abfragen + */ + server.get("/admin/user-tenants/:user_id", async (req, reply) => { + const { user_id } = req.params as { user_id: string }; + + if (!user_id) { + return reply.code(400).send({ error: "user_id required" }); + } + + const {data:user, error: userError} = await server.supabase.from("auth_users").select("*,tenants(*)").eq("id", user_id).single(); + + console.log(userError) + console.log(user) + + if(!user) { + return reply.code(400).send({ error: "faulty user_id presented" }); + } else { + return { user_id, tenants: user.tenants }; + } + + }); + + /** + * Alle User eines Tenants abfragen + * TODO: Aktuell nur Multi Tenant + */ + /*server.get("/admin/tenant-users/:tenant_id", async (req, reply) => { + const { tenant_id } = req.params as { tenant_id: string }; + + if (!tenant_id) { + return reply.code(400).send({ error: "tenant_id required" }); + } + + const { data, error } = await server.supabase + .from("auth_tenant_users") + .select(` + user_id, + role, + users ( id, email, created_at ) + `) + .eq("tenant_id", tenant_id); + + if (error) { + return reply.code(400).send({ error: error.message }); + } + + return { tenant_id, users: data }; + });*/ +} \ No newline at end of file diff --git a/src/routes/auth-authenticated.ts b/src/routes/auth-authenticated.ts new file mode 100644 index 0000000..4cd0d0b --- /dev/null +++ b/src/routes/auth-authenticated.ts @@ -0,0 +1,76 @@ +import { FastifyInstance } from "fastify"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; +import { generateRandomPassword, hashPassword } from "../utils/password" +import { sendMail } from "../utils/mailer" + +export default async function authRoutesAuthenticated(server: FastifyInstance) { + server.post("/auth/password/change", { + schema: { + tags: ["Auth"], + summary: "Reset Password after forced change", + body: { + type: "object", + required: ["old_password", "new_password"], + properties: { + old_password: { type: "string" }, + new_password: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + }, + }, + }, + }, + }, async (req, reply) => { + const { old_password, new_password } = req.body as { old_password: string; new_password: string }; + + console.log(req.user) + + const user_id = req.user?.user_id; // kommt aus JWT Middleware + if (!user_id) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + // Nutzer laden + const { data: user, error } = await server.supabase + .from("auth_users") + .select("id, password_hash, must_change_password") + .eq("id", user_id) + .single(); + + if (error || !user) { + return reply.code(404).send({ error: "User not found" }); + } + + // Altes Passwort prüfen + const valid = await bcrypt.compare(old_password, user.password_hash); + if (!valid) { + return reply.code(401).send({ error: "Old password incorrect" }); + } + + // Neues Passwort hashen + const newHash = await bcrypt.hash(new_password, 10); + + // Speichern + Flag zurücksetzen + const { error: updateError } = await server.supabase + .from("auth_users") + .update({ + password_hash: newHash, + must_change_password: false, + updated_at: new Date().toISOString(), + }) + .eq("id", user_id); + + if (updateError) { + console.log(updateError); + return reply.code(500).send({ error: "Password update failed" }); + } + + return { success: true }; + }); +} \ No newline at end of file diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..3e2b601 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,221 @@ +import { FastifyInstance } from "fastify"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; +import { generateRandomPassword, hashPassword } from "../utils/password" +import { sendMail } from "../utils/mailer" + +export default async function authRoutes(server: FastifyInstance) { + // Registrierung + server.post("/auth/register",{ + schema: { + tags: ["Auth"], + summary: "Register User", + body: { + type: "object", + required: ["email", "password"], + properties: { + email: { type: "string", format: "email" }, + password: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + user: { type: "object" }, + }, + }, + }, + }, + }, async (req, reply) => { + const body = req.body as { email: string; password: string }; + + if (!body.email || !body.password) { + return reply.code(400).send({ error: "Email and password required" }); + } + + // Passwort hashen + const passwordHash = await bcrypt.hash(body.password, 10); + + // User speichern + const { data, error } = await server.supabase + .from("auth_users") + .insert({ email: body.email, password_hash: passwordHash }) + .select("id, email") + .single(); + + if (error) { + return reply.code(400).send({ error: error.message }); + } + + return { user: data }; + }); + + // Login + server.post("/auth/login",{ + schema: { + tags: ["Auth"], + summary: "Login User", + body: { + type: "object", + required: ["email", "password"], + properties: { + email: { type: "string", format: "email" }, + password: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + token: { type: "string" }, + }, + }, + }, + }, + }, async (req, reply) => { + const body = req.body as { email: string; password: string }; + + if (!body.email || !body.password) { + return reply.code(400).send({ error: "Email and password required" }); + } + + console.log(req.tenant) + + /** + * Wenn das Tenant Objekt verfügbar ist, befindet sich das Backend im Single Tenant Modus. + * Es werden nur Benutzer zugelassen, welche auschließlich diesem Tenant angehören. + * Das zeigt sich über das im User gesetzte Tenant Feld + * + * */ + let user = null + let error = null + if(req.tenant) { + // User finden + const { data, error } = await server.supabase + .from("auth_users") + .select("*, tenants!auth_tenant_users(*)") + .eq("email", body.email) + + console.log(data) + console.log(error) + + // @ts-ignore + user = (data || []).find(i => i.tenants.find(x => x.id === req.tenant.id)) + console.log(user) + if(error) { + return reply.code(500).send({ error: "Internal Server Error" }); + } + } else { + // User finden + const { data, error } = await server.supabase + .from("auth_users") + .select("*") + .eq("email", body.email) + .single(); + user = data + if(error) { + console.log(error); + return reply.code(500).send({ error: "Internal Server Error" }); + } + } + + if(!user) { + return reply.code(401).send({ error: "Invalid credentials" }); + } else { + + console.log(user); + console.log(body) + + const valid = await bcrypt.compare(body.password, user.password_hash); + if (!valid) { + return reply.code(401).send({ error: "Invalid credentials" }); + } else { + const token = jwt.sign( + { user_id: user.id, email: user.email, tenant_id: req.tenant ? req.tenant.id : null }, + process.env.JWT_SECRET!, + { expiresIn: "3h" } + ); + + 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.post("/auth/logout", { + schema: { + tags: ["Auth"], + summary: "Logout User (löscht Cookie)" + }, + }, async (req, reply) => { + reply.clearCookie("token", { + path: "/", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + }) + + return { success: true } + }) + + server.post("/auth/password/reset", { + schema: { + tags: ["Auth"], + summary: "Reset Password", + body: { + type: "object", + required: ["email"], + properties: { + email: { type: "string", format: "email" } + } + } + } + }, async (req, reply) => { + const { email } = req.body as { email: string } + + // User finden + const { data: user, error } = await server.supabase + .from("auth_users") + .select("id, email") + .eq("email", email) + .single() + + if (error || !user) { + return reply.code(404).send({ error: "User not found" }) + } + + // Neues Passwort generieren + const plainPassword = generateRandomPassword() + const passwordHash = await hashPassword(plainPassword) + + // In DB updaten + const { error: updateError } = await server.supabase + .from("auth_users") + .update({ password_hash: passwordHash, must_change_password: true }) + .eq("id", user.id) + + if (updateError) { + return reply.code(500).send({ error: "Could not update password" }) + } + + // Mail verschicken + await sendMail( + user.email, + "FEDEO | Dein neues Passwort", + `

Hallo,

+

dein Passwort wurde zurückgesetzt.

+

Neues Passwort: ${plainPassword}

+

Bitte ändere es nach dem Login umgehend.

` + ) + + return { success: true } + }) +} \ No newline at end of file diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..a1d8e19 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,14 @@ +import { FastifyInstance } from "fastify"; + +export default async function routes(server: FastifyInstance) { + server.get("/ping", async () => { + // Testquery gegen DB + const { data, error } = await server.supabase.from("tenants").select("id").limit(1); + + return { + status: "ok", + db: error ? "not connected" : "connected", + tenant_count: data?.length ?? 0 + }; + }); +} \ No newline at end of file diff --git a/src/routes/history.ts b/src/routes/history.ts new file mode 100644 index 0000000..e6d396d --- /dev/null +++ b/src/routes/history.ts @@ -0,0 +1,161 @@ +// src/routes/resources/history.ts +import { FastifyInstance } from "fastify"; + +const columnMap: Record = { + customers: "customer", + vendors: "vendor", + projects: "project", + plants: "plant", + contracts: "contract", + contacts: "contact", + tasks: "task", + vehicles: "vehicle", + events: "event", + files: "file", + products: "product", + inventoryitems: "inventoryitem", + inventoryitemgroups: "inventoryitemgroup", + absencerequests: "absencerequest", + checks: "check", + costcentres: "costcentre", + ownaccounts: "ownaccount", + documentboxes: "documentbox", + hourrates: "hourrate", + services: "service", + roles: "role", +}; + +export default async function resourceHistoryRoutes(server: FastifyInstance) { + server.get<{ + Params: { resource: string; id: string } + }>("/resource/:resource/:id/history", { + schema: { + tags: ["History"], + summary: "Get history entries for a resource", + params: { + type: "object", + required: ["resource", "id"], + properties: { + resource: { type: "string" }, + id: { type: "string" }, + }, + }, + }, + }, async (req, reply) => { + const { resource, id } = req.params; + + const column = columnMap[resource]; + if (!column) { + return reply.code(400).send({ error: `History not supported for resource '${resource}'` }); + } + + const { data, error } = await server.supabase + .from("historyitems") + .select("*") + .eq(column, id) + .order("created_at", { ascending: true }); + + if (error) { + server.log.error(error); + return reply.code(500).send({ error: "Failed to fetch history" }); + } + + const {data:users, error:usersError} = await server.supabase + .from("auth_users") + .select("*, auth_profiles(*), tenants!auth_tenant_users(*)") + + console.log(users) + console.log(usersError) + + const filteredUsers = (users ||[]).filter(i => i.tenants.find((t:any) => t.id === req.user?.tenant_id)) + + + + const dataCombined = data.map(historyitem => { + return { + ...historyitem, + created_by_profile: filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] + } + }) + + + + return dataCombined; + }); + + // Neuen HistoryItem anlegen + server.post<{ + Params: { resource: string; id: string }; + Body: { + text: string; + old_val?: string | null; + new_val?: string | null; + config?: Record; + }; + }>("/resource/:resource/:id/history", { + schema: { + tags: ["History"], + summary: "Create new history entry", + params: { + type: "object", + properties: { + resource: { type: "string" }, + id: { type: "string" } + }, + required: ["resource", "id"] + }, + body: { + type: "object", + properties: { + text: { type: "string" }, + old_val: { type: "string", nullable: true }, + new_val: { type: "string", nullable: true }, + config: { type: "object", nullable: true } + }, + required: ["text"] + }, + response: { + 201: { + type: "object", + properties: { + id: { type: "number" }, + text: { type: "string" }, + created_at: { type: "string" }, + created_by: { type: "string" } + } + } + } + } + }, async (req, reply) => { + const { resource, id } = req.params; + const { text, old_val, new_val, config } = req.body; + + const userId = (req.user as any)?.user_id; + + + const fkField = columnMap[resource]; + if (!fkField) { + return reply.code(400).send({ error: `Unknown resource: ${resource}` }); + } + + const { data, error } = await server.supabase + .from("historyitems") + .insert({ + text, + [fkField]: id, + oldVal: old_val || null, + newVal: new_val || null, + config: config || null, + tenant: (req.user as any)?.tenant_id, + created_by: userId + }) + .select() + .single(); + + if (error) { + return reply.code(500).send({ error: error.message }); + } + + return reply.code(201).send(data); + }); +} diff --git a/src/routes/me.ts b/src/routes/me.ts new file mode 100644 index 0000000..8b12994 --- /dev/null +++ b/src/routes/me.ts @@ -0,0 +1,80 @@ +import { FastifyInstance } from "fastify"; + +export default async function meRoutes(server: FastifyInstance) { + server.get("/me", async (req, reply) => { + const authUser = req.user // kommt aus JWT (user_id + tenant_id) + + if (!authUser) { + return reply.code(401).send({ error: "Unauthorized" }) + } + + const user_id = req.user.user_id + const tenant_id = req.user.tenant_id + + // 1. User laden + const { data: user, error: userError } = await server.supabase + .from("auth_users") + .select("id, email, created_at, must_change_password") + .eq("id", authUser.user_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", user.id) + .eq("tenant_id", activeTenant) + .single() + + profile = profileData + } + + // 5. Permissions laden (über Funktion) + const { data: permissionsData, error: permissionsError } = await server.supabase + .rpc("auth_get_user_permissions", { + uid: user.id, + tid: activeTenant || null + }) + + if(permissionsError) { + console.log(permissionsError) + } + console.log(permissionsData) + + const permissions = permissionsData.map(i => i.permission) || [] + + // 6. Response zurückgeben + return { + user, + tenants, + activeTenant, + profile, + permissions + } + }) +} \ No newline at end of file diff --git a/src/routes/profiles.ts b/src/routes/profiles.ts new file mode 100644 index 0000000..021ef6b --- /dev/null +++ b/src/routes/profiles.ts @@ -0,0 +1,57 @@ +import { FastifyInstance } from "fastify"; + +export default async function authProfilesRoutes(server: FastifyInstance) { + // Ein einzelnes Profil laden (nur im aktuellen Tenant) + server.get<{ + Params: { id: string } + }>("/api/profiles/:id", { + schema: { + tags: ["Auth"], + summary: "Get a profile by user id (only from current tenant)", + params: { + type: "object", + properties: { + id: { type: "string", format: "uuid" } + }, + required: ["id"] + }, + response: { + 200: { + type: "object", + properties: { + id: { type: "string" }, + firstName: { type: "string" }, + lastName: { type: "string" }, + email: { type: "string", nullable: true }, + mobileTel: { type: "string", nullable: true }, + fixedTel: { type: "string", nullable: true }, + role: { type: "string", nullable: true } + } + } + } + } + }, async (req, reply) => { + const { id } = req.params; + const tenantId = (req.user as any)?.tenant_id; + + if (!tenantId) { + return reply.code(400).send({ error: "No tenant selected" }); + } + + const { data, error } = await server.supabase + .from("auth_users") + .select("*, profile(*)") + .eq("id", id) + .eq("tenant", tenantId) + .single(); + + if (error || !data) { + return reply.code(404).send({ error: "User not found or not in tenant" }); + } + + return { + user_id: data.id, + ...data.profile + }; + }); +} \ No newline at end of file diff --git a/src/routes/resources.ts b/src/routes/resources.ts new file mode 100644 index 0000000..9b2a2f4 --- /dev/null +++ b/src/routes/resources.ts @@ -0,0 +1,580 @@ +import { FastifyInstance } from "fastify"; +import {insertHistoryItem } from "../utils/history" +import {diffObjects} from "../utils/diff"; + +const dataTypes: any[] = { + tasks: { + isArchivable: true, + label: "Aufgaben", + labelSingle: "Aufgabe", + isStandardEntity: true, + redirect: true, + historyItemHolder: "task", + supabaseSelectWithInformation: "*, plant(*), project(*), customer(*)", + inputColumns: [ + "Allgemeines", + "Zuweisungen" + ], + showTabs: [{label: 'Informationen'}] + }, + customers: { + isArchivable: true, + label: "Kunden", + labelSingle: "Kunde", + isStandardEntity: true, + redirect:true, + numberRangeHolder: "customerNumber", + historyItemHolder: "customer", + supabaseSortColumn: "customerNumber", + supabaseSelectWithInformation: "*, projects(*), plants(*), contracts(*), contacts(*), createddocuments(*, statementallocations(*)), files(*), events(*)", + inputColumns: [ + "Allgemeines", + "Kontaktdaten" + ], + showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'}] + }, + contacts: { + isArchivable: true, + label: "Kontakte", + labelSingle: "Kontakt", + isStandardEntity: true, + redirect:true, + historyItemHolder: "contact", + supabaseSelectWithInformation: "*, customer(*), vendor(*)", + showTabs:[ + { + label: 'Informationen', + } + ] + }, + contracts: { + isArchivable: true, + label: "Verträge", + labelSingle: "Vertrag", + isStandardEntity: true, + numberRangeHolder: "contractNumber", + redirect:true, + inputColumns: [ + "Allgemeines", + "Abrechnung" + ], + supabaseSelectWithInformation: "*, customer(*), files(*)", + showTabs: [{label: 'Informationen'},{label: 'Dateien'}] + }, + absencerequests: { + isArchivable: true, + label: "Abwesenheiten", + labelSingle: "Abwesenheit", + isStandardEntity: true, + supabaseSortColumn:"startDate", + supabaseSortAscending: false, + supabaseSelectWithInformation: "*", + historyItemHolder: "absencerequest", + redirect:true, + showTabs: [{label: 'Informationen'}] + }, + plants: { + isArchivable: true, + label: "Objekte", + labelSingle: "Objekt", + isStandardEntity: true, + redirect:true, + historyItemHolder: "plant", + supabaseSelectWithInformation: "*, customer(id,name)", + showTabs: [ + { + label: "Informationen" + },{ + label: "Projekte" + },{ + label: "Aufgaben" + },{ + label: "Dateien" + }] + }, + products: { + isArchivable: true, + label: "Artikel", + labelSingle: "Artikel", + isStandardEntity: true, + redirect:true, + supabaseSelectWithInformation: "*, unit(name)", + historyItemHolder: "product", + showTabs: [ + { + label: "Informationen" + } + ] + }, + projects: { + isArchivable: true, + label: "Projekte", + labelSingle: "Projekt", + isStandardEntity: true, + redirect:true, + historyItemHolder: "project", + numberRangeHolder: "projectNumber", + supabaseSelectWithInformation: "*, customer(id,name), plant(id,name), projecttype(name, id), tasks(*, project(id,name), customer(id,name), plant(id,name)), files(*), createddocuments(*, statementallocations(*)), events(*), times(*, profile(id, fullName))", + supabaseSortColumn: "projectNumber", + showTabs: [ + { + key: "information", + label: "Informationen" + }, + { + key: "phases", + label: "Phasen" + },{ + key: "tasks", + label: "Aufgaben" + },{ + key: "files", + label: "Dateien" + },{ + label: "Zeiten" + },{ + label: "Ausgangsbelege" + },{ + label: "Termine" + }/*,{ + key: "timetracking", + label: "Zeiterfassung" + },{ + key: "events", + label: "Termine" + },{ + key: "material", + label: "Material" + }*/] + }, + vehicles: { + isArchivable: true, + label: "Fahrzeuge", + labelSingle: "Fahrzeug", + isStandardEntity: true, + redirect:true, + historyItemHolder: "vehicle", + supabaseSelectWithInformation: "*, checks(*), files(*)", + showTabs: [ + { + label: 'Informationen', + }, { + label: 'Dateien', + }, { + label: 'Überprüfungen', + } + ] + }, + vendors: { + isArchivable: true, + label: "Lieferanten", + labelSingle: "Lieferant", + isStandardEntity: true, + redirect:true, + numberRangeHolder: "vendorNumber", + historyItemHolder: "vendor", + supabaseSortColumn: "vendorNumber", + supabaseSelectWithInformation: "*, contacts(*)", + showTabs: [ + { + label: 'Informationen', + },{ + label: 'Ansprechpartner', + }, { + label: 'Dateien', + } + ] + }, + messages: { + label: "Nachrichten", + labelSingle: "Nachricht" + }, + spaces: { + isArchivable: true, + label: "Lagerplätze", + labelSingle: "Lagerplatz", + isStandardEntity: true, + supabaseSelectWithInformation: "*, files(*)", + supabaseSortColumn: "spaceNumber", + redirect: true, + numberRangeHolder: "spaceNumber", + historyItemHolder: "space", + inputColumns: [ + "Allgemeines", + "Ort" + ], + showTabs: [ + { + label: 'Informationen', + }, { + label: 'Dateien', + },{label: 'Inventarartikel'} + ] + }, + users: { + label: "Benutzer", + labelSingle: "Benutzer" + }, + createddocuments: { + isArchivable: true, + label: "Dokumente", + labelSingle: "Dokument", + supabaseSelectWithInformation: "*, files(*), statementallocations(*)", + }, + incominginvoices: { + label: "Eingangsrechnungen", + labelSingle: "Eingangsrechnung", + redirect:true + }, + inventoryitems: { + isArchivable: true, + label: "Inventarartikel", + labelSingle: "Inventarartikel", + isStandardEntity: true, + supabaseSelectWithInformation: "*, files(*), vendor(id,name), currentSpace(id,name)", + redirect: true, + numberRangeHolder: "articleNumber", + historyItemHolder: "inventoryitem", + inputColumns: [ + "Allgemeines", + "Anschaffung" + ], + showTabs: [ + { + label: 'Informationen', + }, { + label: 'Dateien', + } + ] + }, + inventoryitemgroups: { + isArchivable: true, + label: "Inventarartikelgruppen", + labelSingle: "Inventarartikelgruppe", + isStandardEntity: true, + historyItemHolder: "inventoryitemgroup", + supabaseSelectWithInformation: "*", + redirect: true, + showTabs: [ + { + label: 'Informationen', + } + ] + }, + documentboxes: { + isArchivable: true, + label: "Dokumentenboxen", + labelSingle: "Dokumentenbox", + isStandardEntity: true, + supabaseSelectWithInformation: "*, space(*), files(*)", + redirect: true, + numberRangeHolder: "key", + historyItemHolder: "documentbox", + inputColumns: [ + "Allgemeines", + ], + showTabs: [ + { + label: 'Informationen', + }, { + label: 'Dateien', + } + ] + }, + services: { + isArchivable: true, + label: "Leistungen", + labelSingle: "Leistung", + isStandardEntity: true, + redirect: true, + supabaseSelectWithInformation: "*, unit(*)", + historyItemHolder: "service", + showTabs: [ + { + label: 'Informationen', + } + ] + }, + hourrates: { + isArchivable: true, + label: "Stundensätze", + labelSingle: "Stundensatz", + isStandardEntity: true, + redirect: true, + supabaseSelectWithInformation: "*", + historyItemHolder: "hourrate", + showTabs: [ + { + label: 'Informationen', + } + ] + }, + events: { + isArchivable: true, + label: "Termine", + labelSingle: "Termin", + isStandardEntity: true, + historyItemHolder: "event", + supabaseSelectWithInformation: "*, project(id,name), customer(*)", + redirect: true, + showTabs: [ + { + label: 'Informationen',} + ] + }, + profiles: { + label: "Mitarbeiter", + labelSingle: "Mitarbeiter", + redirect: true, + historyItemHolder: "profile" + }, + workingtimes: { + isArchivable: true, + label: "Anwesenheiten", + labelSingle: "Anwesenheit", + redirect: true, + redirectToList: true + }, + texttemplates: { + isArchivable: true, + label: "Textvorlagen", + labelSingle: "Textvorlage" + }, + bankstatements: { + isArchivable: true, + label: "Kontobewegungen", + labelSingle: "Kontobewegung", + historyItemHolder: "bankStatement", + }, + statementallocations: { + label: "Bankzuweisungen", + labelSingle: "Bankzuweisung" + }, + productcategories: { + isArchivable: true, + label: "Artikelkategorien", + labelSingle: "Artikelkategorie", + isStandardEntity: true, + redirect: true, + supabaseSelectWithInformation: "*", + showTabs: [ + { + label: 'Informationen', + } + ] + }, + servicecategories: { + isArchivable: true, + label: "Leistungskategorien", + labelSingle: "Leistungskategorie", + isStandardEntity: true, + redirect: true, + supabaseSelectWithInformation: "*", + showTabs: [ + { + label: 'Informationen', + } + ] + }, + trackingtrips: { + label: "Fahrten", + labelSingle: "Fahrt", + redirect: true, + historyItemHolder: "trackingtrip", + }, + projecttypes: { + isArchivable: true, + label: "Projekttypen", + labelSingle: "Projekttyp", + redirect: true, + historyItemHolder: "projecttype" + }, + checks: { + isArchivable: true, + label: "Überprüfungen", + labelSingle: "Überprüfung", + isStandardEntity: true, + supabaseSelectWithInformation: "*, vehicle(id,licensePlate), profile(id, fullName), inventoryitem(name), files(*)", + redirect: true, + historyItemHolder: "check", + showTabs: [ + { + label: 'Informationen', + }, {label: 'Dateien'}, {label: 'Ausführungen'}] + }, + roles: { + label: "Rollen", + labelSingle: "Rolle", + redirect:true, + historyItemHolder: "role", + filters: [], + templateColumns: [ + { + key: "name", + label: "Name" + }, { + key: "description", + label: "Beschreibung" + } + ] + }, + costcentres: { + isArchivable: true, + label: "Kostenstellen", + labelSingle: "Kostenstelle", + isStandardEntity: true, + redirect:true, + numberRangeHolder: "number", + historyItemHolder: "costcentre", + supabaseSortColumn: "number", + supabaseSelectWithInformation: "*, project(*), vehicle(*), inventoryitem(*)", + showTabs: [{label: 'Informationen'},{label: 'Auswertung Kostenstelle'}] + }, + ownaccounts: { + isArchivable: true, + label: "zusätzliche Buchungskonten", + labelSingle: "zusätzliches Buchungskonto", + isStandardEntity: true, + redirect:true, + historyItemHolder: "ownaccount", + supabaseSortColumn: "number", + supabaseSelectWithInformation: "*, statementallocations(*, bs_id(*))", + showTabs: [{label: 'Informationen'},{label: 'Buchungen'}] + }, +} + +export default async function resourceRoutes(server: FastifyInstance) { + // Liste + server.get("/resource/:resource", async (req, reply) => { + if (!req.user?.tenant_id) { + return reply.code(400).send({ error: "No tenant selected" }); + } + + const { resource } = req.params as { resource: string }; + + const { data, error } = await server.supabase + .from(resource) + //@ts-ignore + .select(dataTypes[resource].supabaseSelectWithInformation) + .eq("tenant", req.user.tenant_id) + .eq("archived", false); // nur aktive zurückgeben + + if (error) { + return reply.code(400).send({ error: error.message }); + } + + return data; + }); + + // Detail + server.get("/resource/:resource/:id/:with_information?", async (req, reply) => { + if (!req.user?.tenant_id) { + return reply.code(400).send({ error: "No tenant selected" }); + } + + const { resource, id, with_information } = req.params as { resource: string; id: string, with_information: boolean }; + + // @ts-ignore + const { data, error } = await server.supabase.from(resource).select(with_information ? dataTypes[resource].supabaseSelectWithInformation : "*") + .eq("id", id) + .eq("tenant", req.user.tenant_id) + .eq("archived", false) // nur aktive holen + .single(); + + if (error || !data) { + return reply.code(404).send({ error: "Not found" }); + } + + return data; + }); + + // Create + server.post("/resource/:resource", async (req, reply) => { + if (!req.user?.tenant_id) { + return reply.code(400).send({ error: "No tenant selected" }); + } + + const { resource } = req.params as { resource: string }; + const body = req.body as Record; + + const dataType = dataTypes[resource]; + + const { data, error } = await server.supabase + .from(resource) + .insert({ + ...body, + tenant: req.user.tenant_id, + archived: false, // Standardwert + }) + .select("*") + .single(); + + if (error) { + return reply.code(400).send({ error: error.message }); + } + + await insertHistoryItem(server,{ + entity: resource, + entityId: data.id, + action: "created", + created_by: req.user.user_id, + tenant_id: req.user.tenant_id, + oldVal: null, + newVal: data, + text: `${dataType.labelSingle} erstellt`, + }); + + return data; + }); + + // UPDATE (inkl. Soft-Delete/Archive) + server.put("/resource/:resource/:id", async (req, reply) => { + console.log("hi") + const { resource, id } = req.params as { resource: string; id: string } + const body = req.body as Record + + const tenantId = (req.user as any)?.tenant_id + const userId = (req.user as any)?.user_id + + if (!tenantId || !userId) { + return reply.code(401).send({ error: "Unauthorized" }) + } + + // vorherige Version für History laden + const { data: oldItem } = await server.supabase + .from(resource) + .select("*") + .eq("id", id) + .eq("tenant", tenantId) + .single() + + const { data:newItem, error } = await server.supabase + .from(resource) + .update({ ...body, updated_at: new Date().toISOString(), updated_by: userId }) + .eq("id", id) + .eq("tenant", tenantId) + .select() + .single() + + if (error) return reply.code(500).send({ error }) + + const diffs = diffObjects(oldItem, newItem); + + + + for (const d of diffs) { + await insertHistoryItem(server,{ + entity: resource, + entityId: id, + action: d.type, + created_by: userId, + tenant_id: tenantId, + oldVal: d.oldValue ? String(d.oldValue) : null, + newVal: d.newValue ? String(d.newValue) : null, + text: `Feld "${d.label}" ${d.typeLabel}: ${d.oldValue ?? ""} → ${d.newValue ?? ""}`, + }); + } + + return newItem + }) +} \ No newline at end of file diff --git a/src/routes/tenant.ts b/src/routes/tenant.ts new file mode 100644 index 0000000..fff65a5 --- /dev/null +++ b/src/routes/tenant.ts @@ -0,0 +1,64 @@ +import { FastifyInstance } from "fastify"; +import jwt from "jsonwebtoken"; + +export default async function routes(server: FastifyInstance) { + server.get("/tenant", async (req) => { + 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, + }, + process.env.JWT_SECRET!, + { expiresIn: "1h" } + ); + + 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 }; + }); + + +} \ No newline at end of file diff --git a/src/utils/diff.ts b/src/utils/diff.ts new file mode 100644 index 0000000..947372c --- /dev/null +++ b/src/utils/diff.ts @@ -0,0 +1,103 @@ + +import {diffTranslations} from "./diffTranslations"; + +export type DiffChange = { + key: string; + label: string; + oldValue: any; + newValue: any; + type: "created" | "updated" | "deleted" | "unchanged"; + typeLabel: "erstellt" | "geändert" | "gelöscht" | "unverändert"; +}; + +const IGNORED_KEYS = new Set([ + "updated_at", + "updated_by", + "created_at", + "created_by", + "id", + "phases" +]); + +/** + * Vergleicht zwei Objekte und gibt die Änderungen zurück. + * @param obj1 Altes Objekt + * @param obj2 Neues Objekt + * @param ctx Lookup-Objekte (z. B. { projects, customers, vendors, profiles, plants }) + */ +export function diffObjects( + obj1: Record, + obj2: Record, + ctx: Record = {} +): DiffChange[] { + const diffs: DiffChange[] = []; + + const allKeys = new Set([ + ...Object.keys(obj1 || {}), + ...Object.keys(obj2 || {}), + ]); + + for (const key of allKeys) { + if (IGNORED_KEYS.has(key)) continue; // Felder überspringen + + const oldVal = obj1?.[key]; + const newVal = obj2?.[key]; + + console.log(oldVal, key, newVal); + + // Wenn beides null/undefined → ignorieren + if ( + (oldVal === null || oldVal === undefined || oldVal === "" || JSON.stringify(oldVal) === "[]") && + (newVal === null || newVal === undefined || newVal === "" || JSON.stringify(newVal) === "[]") + ) { + continue; + } + + let type: DiffChange["type"] = "unchanged"; + let typeLabel: DiffChange["typeLabel"] = "unverändert"; + if (oldVal === newVal) { + type = "unchanged"; + typeLabel = "unverändert"; + } else if (oldVal === undefined) { + type = "created"; + typeLabel = "erstellt" + } else if (newVal === undefined) { + type = "deleted"; + typeLabel = "gelöscht" + } else { + type = "updated"; + typeLabel = "geändert" + } + + if (type === "unchanged") continue; + + const translation = diffTranslations[key]; + let label = key; + let resolvedOld = oldVal; + let resolvedNew = newVal; + + if (translation) { + label = translation.label; + if (translation.resolve) { + const { oldVal: resOld, newVal: resNew } = translation.resolve( + oldVal, + newVal, + ctx + ); + resolvedOld = resOld; + resolvedNew = resNew; + } + } + + diffs.push({ + key, + label, + typeLabel, + oldValue: resolvedOld ?? "-", + newValue: resolvedNew ?? "-", + type, + }); + } + + return diffs; +} \ No newline at end of file diff --git a/src/utils/diffTranslations.ts b/src/utils/diffTranslations.ts new file mode 100644 index 0000000..290ef38 --- /dev/null +++ b/src/utils/diffTranslations.ts @@ -0,0 +1,165 @@ +import dayjs from "dayjs"; + +type ValueResolver = ( + oldVal: any, + newVal: any, + ctx?: Record +) => { oldVal: any; newVal: any }; + +export const diffTranslations: Record< + string, + { label: string; resolve?: ValueResolver } +> = { + project: { + label: "Projekt", + resolve: (o, n, ctx) => ({ + oldVal: o ? ctx?.projects?.find((i: any) => i.id === o)?.name ?? "-" : "-", + newVal: n ? ctx?.projects?.find((i: any) => i.id === n)?.name ?? "-" : "-", + }), + }, + title: { label: "Titel" }, + type: { label: "Typ" }, + notes: { label: "Notizen" }, + link: { label: "Link" }, + + start: { + label: "Start", + resolve: (o, n) => ({ + oldVal: o ? dayjs(o).format("DD.MM.YYYY HH:mm") : "-", + newVal: n ? dayjs(n).format("DD.MM.YYYY HH:mm") : "-", + }), + }, + end: { + label: "Ende", + resolve: (o, n) => ({ + oldVal: o ? dayjs(o).format("DD.MM.YYYY HH:mm") : "-", + newVal: n ? dayjs(n).format("DD.MM.YYYY HH:mm") : "-", + }), + }, + birthday: { + label: "Geburtstag", + resolve: (o, n) => ({ + oldVal: o ? dayjs(o).format("DD.MM.YYYY") : "-", + newVal: n ? dayjs(n).format("DD.MM.YYYY") : "-", + }), + }, + resources: { + label: "Resourcen", + resolve: (o, n) => ({ + oldVal: Array.isArray(o) ? o.map((i: any) => i.title).join(", ") : "-", + newVal: Array.isArray(n) ? n.map((i: any) => i.title).join(", ") : "-", + }), + }, + + customerNumber: { label: "Kundennummer" }, + active: { + label: "Aktiv", + resolve: (o, n) => ({ + oldVal: o === true ? "Aktiv" : "Gesperrt", + newVal: n === true ? "Aktiv" : "Gesperrt", + }), + }, + isCompany: { + label: "Firmenkunde", + resolve: (o, n) => ({ + oldVal: o === true ? "Firma" : "Privatkunde", + newVal: n === true ? "Firma" : "Privatkunde", + }), + }, + special: { label: "Adresszusatz" }, + street: { label: "Straße & Hausnummer" }, + city: { label: "Ort" }, + zip: { label: "Postleitzahl" }, + country: { label: "Land" }, + web: { label: "Webseite" }, + email: { label: "E-Mail" }, + tel: { label: "Telefon" }, + ustid: { label: "USt-ID" }, + role: { label: "Rolle" }, + phoneHome: { label: "Festnetz" }, + phoneMobile: { label: "Mobiltelefon" }, + salutation: { label: "Anrede" }, + firstName: { label: "Vorname" }, + lastName: { label: "Nachname" }, + name: { label: "Name" }, + nameAddition: { label: "Name Zusatz" }, + approved: { label: "Genehmigt" }, + manufacturer: { label: "Hersteller" }, + purchasePrice: { label: "Kaufpreis" }, + purchaseDate: { label: "Kaufdatum" }, + serialNumber: { label: "Seriennummer" }, + usePlanning: { label: "In Plantafel verwenden" }, + currentSpace: { label: "Lagerplatz" }, + + customer: { + label: "Kunde", + resolve: (o, n, ctx) => ({ + oldVal: o ? ctx?.customers?.find((i: any) => i.id === o)?.name ?? "-" : "-", + newVal: n ? ctx?.customers?.find((i: any) => i.id === n)?.name ?? "-" : "-", + }), + }, + vendor: { + label: "Lieferant", + resolve: (o, n, ctx) => ({ + oldVal: o ? ctx?.vendors?.find((i: any) => i.id === o)?.name ?? "-" : "-", + newVal: n ? ctx?.vendors?.find((i: any) => i.id === n)?.name ?? "-" : "-", + }), + }, + + description: { label: "Beschreibung" }, + categorie: { label: "Kategorie" }, + + profile: { + label: "Mitarbeiter", + resolve: (o, n, ctx) => ({ + oldVal: o ? ctx?.profiles?.find((i: any) => i.id === o)?.fullName ?? "-" : "-", + newVal: n ? ctx?.profiles?.find((i: any) => i.id === n)?.fullName ?? "-" : "-", + }), + }, + plant: { + label: "Objekt", + resolve: (o, n, ctx) => ({ + oldVal: o ? ctx?.plants?.find((i: any) => i.id === o)?.name ?? "-" : "-", + newVal: n ? ctx?.plants?.find((i: any) => i.id === n)?.name ?? "-" : "-", + }), + }, + + annualPaidLeaveDays: { label: "Urlaubstage" }, + employeeNumber: { label: "Mitarbeiternummer" }, + weeklyWorkingDays: { label: "Wöchentliche Arbeitstage" }, + weeklyWorkingHours: { label: "Wöchentliche Arbeitszeit" }, + customerRef: { label: "Referenz des Kunden" }, + + licensePlate: { label: "Kennzeichen" }, + tankSize: { label: "Tankvolumen" }, + towingCapacity: { label: "Anhängelast" }, + color: { label: "Farbe" }, + customPaymentDays: { label: "Zahlungsziel in Tagen" }, + customSurchargePercentage: { label: "Individueller Aufschlag" }, + powerInKW: { label: "Leistung" }, + + driver: { + label: "Fahrer", + resolve: (o, n, ctx) => ({ + oldVal: o ? ctx?.profiles?.find((i: any) => i.id === o)?.fullName ?? "-" : "-", + newVal: n ? ctx?.profiles?.find((i: any) => i.id === n)?.fullName ?? "-" : "-", + }), + }, + + projecttype: { label: "Projekttyp" }, + + fixed: { + label: "Festgeschrieben", + resolve: (o, n) => ({ + oldVal: o === true ? "Ja" : "Nein", + newVal: n === true ? "Ja" : "Nein", + }), + }, + archived: { + label: "Archiviert", + resolve: (o, n) => ({ + oldVal: o === true ? "Ja" : "Nein", + newVal: n === true ? "Ja" : "Nein", + }), + }, +}; diff --git a/src/utils/history.ts b/src/utils/history.ts new file mode 100644 index 0000000..5cc39b0 --- /dev/null +++ b/src/utils/history.ts @@ -0,0 +1,69 @@ +import { FastifyInstance } from "fastify" + +export async function insertHistoryItem( + server: FastifyInstance, + params: { + tenant_id: number + created_by: string | null + entity: string + entityId: string | number + action: "created" | "updated" | "unchanged" | "deleted" | "archived" + oldVal?: any + newVal?: any + text?: string + } +) { + const textMap = { + created: `Neuer Eintrag in ${params.entity} erstellt`, + updated: `Eintrag in ${params.entity} geändert`, + archived: `Eintrag in ${params.entity} archiviert`, + deleted: `Eintrag in ${params.entity} gelöscht` + } + + const columnMap: Record = { + customers: "customer", + vendors: "vendor", + projects: "project", + plants: "plant", + contacts: "contact", + inventoryitems: "inventoryitem", + products: "product", + profiles: "profile", + absencerequests: "absencerequest", + events: "event", + tasks: "task", + vehicles: "vehicle", + costcentres: "costcentre", + ownaccounts: "ownaccount", + documentboxes: "documentbox", + hourrates: "hourrate", + services: "service", + roles: "role", + checks: "check", + spaces: "space", + trackingtrips: "trackingtrip", + createddocuments: "createddocument", + inventoryitemgroups: "inventoryitemgroup" + } + + const fkColumn = columnMap[params.entity] + if (!fkColumn) { + server.log.warn(`Keine History-Spalte für Entity: ${params.entity}`) + return + } + + const entry = { + tenant: params.tenant_id, + created_by: params.created_by, + text: params.text || textMap[params.action], + action: params.action, + [fkColumn]: params.entityId, + oldVal: params.oldVal ? JSON.stringify(params.oldVal) : null, + newVal: params.newVal ? JSON.stringify(params.newVal) : null + } + + const { error } = await server.supabase.from("historyitems").insert([entry]) + if (error) { // @ts-ignore + console.log(error) + } +} diff --git a/src/utils/mailer.ts b/src/utils/mailer.ts new file mode 100644 index 0000000..34f94e1 --- /dev/null +++ b/src/utils/mailer.ts @@ -0,0 +1,32 @@ +import nodemailer from "nodemailer" + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT) || 587, + secure: process.env.SMTP_SSL === "true", // true für 465, false für andere Ports + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}) + +export async function sendMail( + to: string, + subject: string, + html: string +): Promise<{ success: boolean; info?: any; error?: any }> { + try { + const info = await transporter.sendMail({ + from: process.env.MAIL_FROM, + to, + subject, + html, + }) + + // Nodemailer liefert eine Info-Response zurück + return { success: true, info } + } catch (err) { + console.error("❌ Fehler beim Mailversand:", err) + return { success: false, error: err } + } +} \ No newline at end of file diff --git a/src/utils/password.ts b/src/utils/password.ts new file mode 100644 index 0000000..11851ae --- /dev/null +++ b/src/utils/password.ts @@ -0,0 +1,15 @@ +import bcrypt from "bcrypt" + +export function generateRandomPassword(length = 12): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*" + let password = "" + for (let i = 0; i < length; i++) { + password += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return password +} + +export async function hashPassword(password: string): Promise { + const saltRounds = 10 + return bcrypt.hash(password, saltRounds) +} \ No newline at end of file