Changes
This commit is contained in:
32
TODO.md
Normal file
32
TODO.md
Normal file
@@ -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`)
|
||||
|
||||
---
|
||||
65
src/index.ts
Normal file
65
src/index.ts
Normal file
@@ -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();
|
||||
54
src/plugins/auth.ts
Normal file
54
src/plugins/auth.ts
Normal file
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
16
src/plugins/cors.ts
Normal file
16
src/plugins/cors.ts
Normal file
@@ -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
|
||||
});
|
||||
});
|
||||
19
src/plugins/supabase.ts
Normal file
19
src/plugins/supabase.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
29
src/plugins/swagger.ts
Normal file
29
src/plugins/swagger.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
44
src/plugins/tenant.ts
Normal file
44
src/plugins/tenant.ts
Normal file
@@ -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<string, any>;
|
||||
};
|
||||
}
|
||||
}
|
||||
94
src/routes/admin.ts
Normal file
94
src/routes/admin.ts
Normal file
@@ -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 };
|
||||
});*/
|
||||
}
|
||||
76
src/routes/auth-authenticated.ts
Normal file
76
src/routes/auth-authenticated.ts
Normal file
@@ -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 };
|
||||
});
|
||||
}
|
||||
221
src/routes/auth.ts
Normal file
221
src/routes/auth.ts
Normal file
@@ -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",
|
||||
`<p>Hallo,</p>
|
||||
<p>dein Passwort wurde zurückgesetzt.</p>
|
||||
<p><strong>Neues Passwort:</strong> ${plainPassword}</p>
|
||||
<p>Bitte ändere es nach dem Login umgehend.</p>`
|
||||
)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
14
src/routes/health.ts
Normal file
14
src/routes/health.ts
Normal file
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
161
src/routes/history.ts
Normal file
161
src/routes/history.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// src/routes/resources/history.ts
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
const columnMap: Record<string, string> = {
|
||||
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<string, any>;
|
||||
};
|
||||
}>("/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);
|
||||
});
|
||||
}
|
||||
80
src/routes/me.ts
Normal file
80
src/routes/me.ts
Normal file
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
57
src/routes/profiles.ts
Normal file
57
src/routes/profiles.ts
Normal file
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
580
src/routes/resources.ts
Normal file
580
src/routes/resources.ts
Normal file
@@ -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<string, any>;
|
||||
|
||||
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<string, any>
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
64
src/routes/tenant.ts
Normal file
64
src/routes/tenant.ts
Normal file
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
103
src/utils/diff.ts
Normal file
103
src/utils/diff.ts
Normal file
@@ -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<string, any>,
|
||||
obj2: Record<string, any>,
|
||||
ctx: Record<string, any> = {}
|
||||
): 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;
|
||||
}
|
||||
165
src/utils/diffTranslations.ts
Normal file
165
src/utils/diffTranslations.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
type ValueResolver = (
|
||||
oldVal: any,
|
||||
newVal: any,
|
||||
ctx?: Record<string, any>
|
||||
) => { 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",
|
||||
}),
|
||||
},
|
||||
};
|
||||
69
src/utils/history.ts
Normal file
69
src/utils/history.ts
Normal file
@@ -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<string, string> = {
|
||||
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)
|
||||
}
|
||||
}
|
||||
32
src/utils/mailer.ts
Normal file
32
src/utils/mailer.ts
Normal file
@@ -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 }
|
||||
}
|
||||
}
|
||||
15
src/utils/password.ts
Normal file
15
src/utils/password.ts
Normal file
@@ -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<string> {
|
||||
const saltRounds = 10
|
||||
return bcrypt.hash(password, saltRounds)
|
||||
}
|
||||
Reference in New Issue
Block a user