Changes
This commit is contained in:
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 };
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user