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

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