This commit is contained in:
2025-08-31 18:29:29 +02:00
parent aeaba64865
commit 97a095b422
21 changed files with 1990 additions and 0 deletions

65
src/index.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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 };
});*/
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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)
}