Added Backend
This commit is contained in:
117
backend/src/routes/admin.ts
Normal file
117
backend/src/routes/admin.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import {
|
||||
authTenantUsers,
|
||||
authUsers,
|
||||
tenants,
|
||||
} from "../../db/schema";
|
||||
|
||||
export default async function adminRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/add-user-to-tenant
|
||||
// -------------------------------------------------------------
|
||||
server.post("/admin/add-user-to-tenant", async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as {
|
||||
user_id: string;
|
||||
tenant_id: number;
|
||||
role?: string;
|
||||
mode?: "single" | "multi";
|
||||
};
|
||||
|
||||
if (!body.user_id || !body.tenant_id) {
|
||||
return reply.code(400).send({
|
||||
error: "user_id and tenant_id required"
|
||||
});
|
||||
}
|
||||
|
||||
const mode = body.mode ?? "multi";
|
||||
|
||||
// ----------------------------
|
||||
// SINGLE MODE → alte Verknüpfungen löschen
|
||||
// ----------------------------
|
||||
if (mode === "single") {
|
||||
await server.db
|
||||
.delete(authTenantUsers)
|
||||
.where(eq(authTenantUsers.user_id, body.user_id));
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Neue Verknüpfung hinzufügen
|
||||
// ----------------------------
|
||||
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
// @ts-ignore
|
||||
.values({
|
||||
user_id: body.user_id,
|
||||
tenantId: body.tenant_id,
|
||||
role: body.role ?? "member",
|
||||
});
|
||||
|
||||
return { success: true, mode };
|
||||
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/add-user-to-tenant:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET /admin/user-tenants/:user_id
|
||||
// -------------------------------------------------------------
|
||||
server.get("/admin/user-tenants/:user_id", async (req, reply) => {
|
||||
try {
|
||||
const { user_id } = req.params as { user_id: string };
|
||||
|
||||
if (!user_id) {
|
||||
return reply.code(400).send({ error: "user_id required" });
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// 1) User existiert?
|
||||
// ----------------------------
|
||||
const [user] = await server.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, user_id))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(400).send({ error: "faulty user_id presented" });
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// 2) Tenants Join über auth_tenant_users
|
||||
// ----------------------------
|
||||
const tenantRecords = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
locked: tenants.locked,
|
||||
numberRanges: tenants.numberRanges,
|
||||
extraModules: tenants.extraModules,
|
||||
})
|
||||
.from(authTenantUsers)
|
||||
.innerJoin(
|
||||
tenants,
|
||||
eq(authTenantUsers.tenant_id, tenants.id)
|
||||
)
|
||||
.where(eq(authTenantUsers.user_id, user_id));
|
||||
|
||||
return {
|
||||
user_id,
|
||||
tenants: tenantRecords,
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/user-tenants:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
96
backend/src/routes/auth/auth-authenticated.ts
Normal file
96
backend/src/routes/auth/auth-authenticated.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import bcrypt from "bcrypt"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
|
||||
|
||||
export default async function authRoutesAuthenticated(server: FastifyInstance) {
|
||||
|
||||
server.post("/auth/password/change", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Change password (after login or forced reset)",
|
||||
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) => {
|
||||
|
||||
try {
|
||||
const { old_password, new_password } = req.body as {
|
||||
old_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
const userId = req.user?.user_id
|
||||
if (!userId) {
|
||||
//@ts-ignore
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 1) User laden
|
||||
// -----------------------------------------------------
|
||||
const [user] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
passwordHash: authUsers.passwordHash,
|
||||
mustChangePassword: authUsers.must_change_password
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!user) {
|
||||
//@ts-ignore
|
||||
return reply.code(404).send({ error: "User not found" })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 2) Altes PW prüfen
|
||||
// -----------------------------------------------------
|
||||
const valid = await bcrypt.compare(old_password, user.passwordHash)
|
||||
if (!valid) {
|
||||
//@ts-ignore
|
||||
return reply.code(401).send({ error: "Old password incorrect" })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 3) Neues PW hashen
|
||||
// -----------------------------------------------------
|
||||
const newHash = await bcrypt.hash(new_password, 10)
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 4) Updaten
|
||||
// -----------------------------------------------------
|
||||
await server.db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
passwordHash: newHash,
|
||||
must_change_password: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(authUsers.id, userId))
|
||||
|
||||
return { success: true }
|
||||
|
||||
} catch (err) {
|
||||
console.error("POST /auth/password/change ERROR:", err)
|
||||
//@ts-ignore
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
224
backend/src/routes/auth/auth.ts
Normal file
224
backend/src/routes/auth/auth.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { generateRandomPassword, hashPassword } from "../../utils/password";
|
||||
import { sendMail } from "../../utils/mailer";
|
||||
import { secrets } from "../../utils/secrets";
|
||||
|
||||
import { authUsers } from "../../../db/schema";
|
||||
import { authTenantUsers } from "../../../db/schema";
|
||||
import { tenants } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export default async function authRoutes(server: FastifyInstance) {
|
||||
|
||||
// -----------------------------------------------------
|
||||
// REGISTER
|
||||
// -----------------------------------------------------
|
||||
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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const body = req.body as { email: string; password: string };
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.password, 10);
|
||||
|
||||
const [user] = await server.db
|
||||
.insert(authUsers)
|
||||
.values({
|
||||
email: body.email.toLowerCase(),
|
||||
passwordHash,
|
||||
})
|
||||
.returning({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
});
|
||||
|
||||
return { user };
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const body = req.body as { email: string; password: string };
|
||||
|
||||
let user: any = null;
|
||||
|
||||
// -------------------------------
|
||||
// SINGLE TENANT MODE
|
||||
// -------------------------------
|
||||
/* if (req.tenant) {
|
||||
const tenantId = req.tenant.id;
|
||||
|
||||
const result = await server.db
|
||||
.select({
|
||||
user: authUsers,
|
||||
})
|
||||
.from(authUsers)
|
||||
.innerJoin(
|
||||
authTenantUsers,
|
||||
eq(authTenantUsers.userId, authUsers.id)
|
||||
)
|
||||
.innerJoin(
|
||||
tenants,
|
||||
eq(authTenantUsers.tenantId, tenants.id)
|
||||
)
|
||||
.where(and(
|
||||
eq(authUsers.email, body.email.toLowerCase()),
|
||||
eq(authTenantUsers.tenantId, tenantId)
|
||||
));
|
||||
|
||||
if (result.length === 0) {
|
||||
return reply.code(401).send({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
user = result[0].user;
|
||||
|
||||
// -------------------------------
|
||||
// MULTI TENANT MODE
|
||||
// -------------------------------
|
||||
} else {*/
|
||||
const [found] = await server.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, body.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!found) {
|
||||
return reply.code(401).send({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
user = found;
|
||||
/*}*/
|
||||
|
||||
// Passwort prüfen
|
||||
const valid = await bcrypt.compare(body.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
tenant_id: req.tenant?.id ?? null,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: "6h" }
|
||||
);
|
||||
|
||||
reply.setCookie("token", token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 3,
|
||||
});
|
||||
|
||||
return { token };
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------
|
||||
// LOGOUT
|
||||
// -----------------------------------------------------
|
||||
server.post("/auth/logout", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Logout User"
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.clearCookie("token", {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------
|
||||
// PASSWORD RESET
|
||||
// -----------------------------------------------------
|
||||
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 };
|
||||
|
||||
const [user] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
const plainPassword = generateRandomPassword();
|
||||
const passwordHash = await hashPassword(plainPassword);
|
||||
|
||||
|
||||
await server.db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
passwordHash,
|
||||
// @ts-ignore
|
||||
mustChangePassword: true,
|
||||
})
|
||||
.where(eq(authUsers.id, user.id));
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
140
backend/src/routes/auth/me.ts
Normal file
140
backend/src/routes/auth/me.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
authUsers,
|
||||
authTenantUsers,
|
||||
tenants,
|
||||
authProfiles,
|
||||
authUserRoles,
|
||||
authRoles,
|
||||
authRolePermissions,
|
||||
} from "../../../db/schema"
|
||||
import { eq, and, or, isNull } from "drizzle-orm"
|
||||
|
||||
export default async function meRoutes(server: FastifyInstance) {
|
||||
server.get("/me", async (req, reply) => {
|
||||
try {
|
||||
const authUser = req.user
|
||||
|
||||
if (!authUser) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const userId = authUser.user_id
|
||||
const activeTenantId = authUser.tenant_id
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 1) USER LADEN
|
||||
// ----------------------------------------------------
|
||||
const userResult = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
created_at: authUsers.created_at,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.limit(1)
|
||||
|
||||
const user = userResult[0]
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: "User not found" })
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 2) TENANTS LADEN
|
||||
// ----------------------------------------------------
|
||||
const tenantRows = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
locked: tenants.locked,
|
||||
extraModules: tenants.extraModules,
|
||||
businessInfo: tenants.businessInfo,
|
||||
numberRanges: tenants.numberRanges,
|
||||
dokuboxkey: tenants.dokuboxkey,
|
||||
standardEmailForInvoices: tenants.standardEmailForInvoices,
|
||||
standardPaymentDays: tenants.standardPaymentDays,
|
||||
})
|
||||
.from(authTenantUsers)
|
||||
.innerJoin(tenants, eq(authTenantUsers.tenant_id, tenants.id))
|
||||
.where(eq(authTenantUsers.user_id, userId))
|
||||
|
||||
const tenantList = tenantRows ?? []
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 3) ACTIVE TENANT
|
||||
// ----------------------------------------------------
|
||||
const activeTenant = activeTenantId
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 4) PROFIL LADEN
|
||||
// ----------------------------------------------------
|
||||
let profile = null
|
||||
if (activeTenantId) {
|
||||
const profileResult = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.user_id, userId),
|
||||
eq(authProfiles.tenant_id, activeTenantId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
profile = profileResult?.[0] ?? null
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 5) PERMISSIONS — RPC ERSETZT
|
||||
// ----------------------------------------------------
|
||||
const permissionRows =
|
||||
(await server.db
|
||||
.select({
|
||||
permission: authRolePermissions.permission,
|
||||
})
|
||||
.from(authUserRoles)
|
||||
.innerJoin(
|
||||
authRoles,
|
||||
and(
|
||||
eq(authRoles.id, authUserRoles.role_id),
|
||||
or(
|
||||
isNull(authRoles.tenant_id), // globale Rolle
|
||||
eq(authRoles.tenant_id, activeTenantId) // tenant-spezifische Rolle
|
||||
)
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
authRolePermissions,
|
||||
eq(authRolePermissions.role_id, authRoles.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(authUserRoles.user_id, userId),
|
||||
eq(authUserRoles.tenant_id, activeTenantId)
|
||||
)
|
||||
)) ?? []
|
||||
|
||||
const permissions = Array.from(
|
||||
new Set(permissionRows.map((p) => p.permission))
|
||||
)
|
||||
|
||||
// ----------------------------------------------------
|
||||
// RESPONSE
|
||||
// ----------------------------------------------------
|
||||
return {
|
||||
user,
|
||||
tenants: tenantList,
|
||||
activeTenant,
|
||||
profile,
|
||||
permissions,
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("ERROR in /me route:", err)
|
||||
return reply.code(500).send({ error: "Internal server error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
129
backend/src/routes/auth/user.ts
Normal file
129
backend/src/routes/auth/user.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
import {
|
||||
authUsers,
|
||||
authProfiles,
|
||||
} from "../../../db/schema"
|
||||
|
||||
export default async function userRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET /user/:id
|
||||
// -------------------------------------------------------------
|
||||
server.get("/user/:id", async (req, reply) => {
|
||||
try {
|
||||
const authUser = req.user
|
||||
const { id } = req.params as { id: string }
|
||||
|
||||
if (!authUser) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
// 1️⃣ User laden
|
||||
const [user] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
created_at: authUsers.created_at,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, id))
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" })
|
||||
}
|
||||
|
||||
// 2️⃣ Profil im Tenant
|
||||
let profile = null
|
||||
|
||||
if (authUser.tenant_id) {
|
||||
const [profileRow] = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.user_id, id),
|
||||
eq(authProfiles.tenant_id, authUser.tenant_id)
|
||||
)
|
||||
)
|
||||
|
||||
profile = profileRow || null
|
||||
}
|
||||
|
||||
return { user, profile }
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("/user/:id ERROR", err)
|
||||
return reply.code(500).send({ error: err.message || "Internal error" })
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// PUT /user/:id/profile
|
||||
// -------------------------------------------------------------
|
||||
server.put("/user/:id/profile", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string }
|
||||
const { data } = req.body as { data?: Record<string, any> }
|
||||
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
return reply.code(400).send({ error: "data object required" })
|
||||
}
|
||||
|
||||
// 1️⃣ Profil für diesen Tenant laden (damit wir die ID kennen)
|
||||
const [profile] = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.user_id, id),
|
||||
eq(authProfiles.tenant_id, req.user.tenant_id)
|
||||
)
|
||||
)
|
||||
|
||||
if (!profile) {
|
||||
return reply.code(404).send({ error: "Profile not found in tenant" })
|
||||
}
|
||||
|
||||
// 2️⃣ Timestamp-Felder normalisieren (falls welche drin sind)
|
||||
const normalizeDate = (val: any) => {
|
||||
if (!val) return null
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const updateData: any = { ...data }
|
||||
|
||||
// bekannte Date-Felder prüfen
|
||||
if (data.entry_date !== undefined)
|
||||
updateData.entry_date = normalizeDate(data.entry_date)
|
||||
|
||||
if (data.birthday !== undefined)
|
||||
updateData.birthday = normalizeDate(data.birthday)
|
||||
|
||||
if (data.created_at !== undefined)
|
||||
updateData.created_at = normalizeDate(data.created_at)
|
||||
|
||||
updateData.updated_at = new Date()
|
||||
|
||||
// 3️⃣ Update durchführen
|
||||
const [updatedProfile] = await server.db
|
||||
.update(authProfiles)
|
||||
.set(updateData)
|
||||
.where(eq(authProfiles.id, profile.id))
|
||||
.returning()
|
||||
|
||||
return { profile: updatedProfile }
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("PUT /user/:id/profile ERROR", err)
|
||||
return reply.code(500).send({ error: err.message || "Internal server error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
236
backend/src/routes/banking.ts
Normal file
236
backend/src/routes/banking.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import axios from "axios"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { insertHistoryItem } from "../utils/history"
|
||||
|
||||
import {
|
||||
bankrequisitions,
|
||||
statementallocations,
|
||||
} from "../../db/schema"
|
||||
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
} from "drizzle-orm"
|
||||
|
||||
|
||||
export default async function bankingRoutes(server: FastifyInstance) {
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 🔐 GoCardLess Token Handling
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL
|
||||
const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID
|
||||
const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY
|
||||
|
||||
let tokenData: any = null
|
||||
|
||||
const getToken = async () => {
|
||||
const res = await axios.post(`${goCardLessBaseUrl}/token/new/`, {
|
||||
secret_id: goCardLessSecretId,
|
||||
secret_key: goCardLessSecretKey,
|
||||
})
|
||||
|
||||
tokenData = res.data
|
||||
tokenData.created_at = new Date().toISOString()
|
||||
|
||||
server.log.info("GoCardless token refreshed.")
|
||||
}
|
||||
|
||||
const checkToken = async () => {
|
||||
if (!tokenData) return await getToken()
|
||||
|
||||
const expired = dayjs(tokenData.created_at)
|
||||
.add(tokenData.access_expires, "seconds")
|
||||
.isBefore(dayjs())
|
||||
|
||||
if (expired) {
|
||||
server.log.info("Refreshing expired GoCardless token …")
|
||||
await getToken()
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 🔗 Create GoCardless Banking Link
|
||||
// ------------------------------------------------------------------
|
||||
server.get("/banking/link/:institutionid", async (req, reply) => {
|
||||
try {
|
||||
await checkToken()
|
||||
|
||||
const { institutionid } = req.params as { institutionid: string }
|
||||
const tenantId = req.user?.tenant_id
|
||||
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${goCardLessBaseUrl}/requisitions/`,
|
||||
{
|
||||
redirect: "https://app.fedeo.de/settings/banking",
|
||||
institution_id: institutionid,
|
||||
user_language: "de",
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenData.access}` },
|
||||
}
|
||||
)
|
||||
|
||||
// DB: Requisition speichern
|
||||
await server.db.insert(bankrequisitions).values({
|
||||
id: data.id,
|
||||
tenant: tenantId,
|
||||
institutionId: institutionid,
|
||||
status: data.status,
|
||||
})
|
||||
|
||||
return reply.send({ link: data.link })
|
||||
} catch (err: any) {
|
||||
server.log.error(err?.response?.data || err)
|
||||
return reply.code(500).send({ error: "Failed to generate link" })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 🏦 Check Bank Institutions
|
||||
// ------------------------------------------------------------------
|
||||
server.get("/banking/institutions/:bic", async (req, reply) => {
|
||||
try {
|
||||
const { bic } = req.params as { bic: string }
|
||||
if (!bic) return reply.code(400).send("BIC missing")
|
||||
|
||||
await checkToken()
|
||||
|
||||
const { data } = await axios.get(
|
||||
`${goCardLessBaseUrl}/institutions/?country=de`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
)
|
||||
|
||||
const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase())
|
||||
|
||||
if (!bank) return reply.code(404).send("Bank not found")
|
||||
|
||||
return reply.send(bank)
|
||||
} catch (err: any) {
|
||||
server.log.error(err?.response?.data || err)
|
||||
return reply.code(500).send("Failed to fetch institutions")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 📄 Get Requisition Details
|
||||
// ------------------------------------------------------------------
|
||||
server.get("/banking/requisitions/:reqId", async (req, reply) => {
|
||||
try {
|
||||
const { reqId } = req.params as { reqId: string }
|
||||
if (!reqId) return reply.code(400).send("Requisition ID missing")
|
||||
|
||||
await checkToken()
|
||||
|
||||
const { data } = await axios.get(
|
||||
`${goCardLessBaseUrl}/requisitions/${reqId}`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
)
|
||||
|
||||
// Load account details
|
||||
if (data.accounts) {
|
||||
data.accounts = await Promise.all(
|
||||
data.accounts.map(async (accId: string) => {
|
||||
const { data: acc } = await axios.get(
|
||||
`${goCardLessBaseUrl}/accounts/${accId}`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
)
|
||||
return acc
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return reply.send(data)
|
||||
} catch (err: any) {
|
||||
server.log.error(err?.response?.data || err)
|
||||
return reply.code(500).send("Failed to fetch requisition details")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 💰 Create Statement Allocation
|
||||
// ------------------------------------------------------------------
|
||||
server.post("/banking/statements", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { data: payload } = req.body as { data: any }
|
||||
|
||||
const inserted = await server.db.insert(statementallocations).values({
|
||||
...payload,
|
||||
tenant: req.user.tenant_id
|
||||
}).returning()
|
||||
|
||||
const createdRecord = inserted[0]
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: createdRecord.id,
|
||||
action: "created",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: null,
|
||||
newVal: createdRecord,
|
||||
text: "Buchung erstellt",
|
||||
})
|
||||
|
||||
return reply.send(createdRecord)
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Failed to create statement" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 🗑 Delete Statement Allocation
|
||||
// ------------------------------------------------------------------
|
||||
server.delete("/banking/statements/:id", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
|
||||
const oldRecord = await server.db
|
||||
.select()
|
||||
.from(statementallocations)
|
||||
.where(eq(statementallocations.id, id))
|
||||
.limit(1)
|
||||
|
||||
const old = oldRecord[0]
|
||||
|
||||
if (!old) return reply.code(404).send({ error: "Record not found" })
|
||||
|
||||
await server.db
|
||||
.delete(statementallocations)
|
||||
.where(eq(statementallocations.id, id))
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: id,
|
||||
action: "deleted",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: old,
|
||||
newVal: null,
|
||||
text: "Buchung gelöscht",
|
||||
})
|
||||
|
||||
return reply.send({ success: true })
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Failed to delete statement" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
98
backend/src/routes/devices/rfid.ts
Normal file
98
backend/src/routes/devices/rfid.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {and, desc, eq} from "drizzle-orm";
|
||||
import {authProfiles, devices, stafftimeevents} from "../../../db/schema";
|
||||
|
||||
export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||
server.post(
|
||||
"/rfid/createevent/:terminal_id",
|
||||
async (req, reply) => {
|
||||
try {
|
||||
|
||||
const {rfid_id} = req.body as {rfid_id: string};
|
||||
const {terminal_id} = req.params as {terminal_id: string};
|
||||
|
||||
if(!rfid_id ||!terminal_id) {
|
||||
console.log(`Missing Params`);
|
||||
return reply.code(400).send(`Missing Params`)
|
||||
}
|
||||
|
||||
const device = await server.db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(
|
||||
eq(devices.externalId, terminal_id)
|
||||
|
||||
)
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
if(!device) {
|
||||
console.log(`Device ${terminal_id} not found`);
|
||||
return reply.code(400).send(`Device ${terminal_id} not found`)
|
||||
|
||||
}
|
||||
|
||||
const profile = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.tenant_id, device.tenant),
|
||||
eq(authProfiles.token_id, rfid_id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
if(!profile) {
|
||||
console.log(`Profile for Token ${rfid_id} not found`);
|
||||
return reply.code(400).send(`Profile for Token ${rfid_id} not found`)
|
||||
|
||||
}
|
||||
|
||||
const lastEvent = await server.db
|
||||
.select()
|
||||
.from(stafftimeevents)
|
||||
.where(
|
||||
eq(stafftimeevents.user_id, profile.user_id)
|
||||
)
|
||||
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
console.log(lastEvent)
|
||||
|
||||
|
||||
const dataToInsert = {
|
||||
tenant_id: device.tenant,
|
||||
user_id: profile.user_id,
|
||||
actortype: "system",
|
||||
eventtime: new Date(),
|
||||
eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start",
|
||||
source: "WEB"
|
||||
}
|
||||
|
||||
console.log(dataToInsert)
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(dataToInsert)
|
||||
.returning()
|
||||
|
||||
return created
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
return reply.code(400).send({ error: err.message })
|
||||
}
|
||||
|
||||
|
||||
|
||||
console.log(req.body)
|
||||
|
||||
return
|
||||
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
262
backend/src/routes/emailAsUser.ts
Normal file
262
backend/src/routes/emailAsUser.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import nodemailer from "nodemailer"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { sendMailAsUser } from "../utils/emailengine"
|
||||
import { encrypt, decrypt } from "../utils/crypt"
|
||||
import { userCredentials } from "../../db/schema"
|
||||
// Pfad ggf. anpassen
|
||||
|
||||
// @ts-ignore
|
||||
import MailComposer from "nodemailer/lib/mail-composer/index.js"
|
||||
import { ImapFlow } from "imapflow"
|
||||
|
||||
export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// CREATE OR UPDATE EMAIL ACCOUNT
|
||||
// ======================================================================
|
||||
server.post("/email/accounts/:id?", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id?: string }
|
||||
|
||||
const body = req.body as {
|
||||
email: string
|
||||
password: string
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
smtp_ssl: boolean
|
||||
imap_host: string
|
||||
imap_port: number
|
||||
imap_ssl: boolean
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// UPDATE EXISTING
|
||||
// -----------------------------
|
||||
if (id) {
|
||||
const saveData = {
|
||||
emailEncrypted: body.email ? encrypt(body.email) : undefined,
|
||||
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
|
||||
smtpHostEncrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined,
|
||||
smtpPort: body.smtp_port,
|
||||
smtpSsl: body.smtp_ssl,
|
||||
imapHostEncrypted: body.imap_host ? encrypt(body.imap_host) : undefined,
|
||||
imapPort: body.imap_port,
|
||||
imapSsl: body.imap_ssl,
|
||||
}
|
||||
|
||||
await server.db
|
||||
.update(userCredentials)
|
||||
//@ts-ignore
|
||||
.set(saveData)
|
||||
.where(eq(userCredentials.id, id))
|
||||
|
||||
return reply.send({ success: true })
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// CREATE NEW
|
||||
// -----------------------------
|
||||
const insertData = {
|
||||
userId: req.user.user_id,
|
||||
tenantId: req.user.tenant_id,
|
||||
type: "mail",
|
||||
|
||||
emailEncrypted: encrypt(body.email),
|
||||
passwordEncrypted: encrypt(body.password),
|
||||
|
||||
smtpHostEncrypted: encrypt(body.smtp_host),
|
||||
smtpPort: body.smtp_port,
|
||||
smtpSsl: body.smtp_ssl,
|
||||
|
||||
imapHostEncrypted: encrypt(body.imap_host),
|
||||
imapPort: body.imap_port,
|
||||
imapSsl: body.imap_ssl,
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
await server.db.insert(userCredentials).values(insertData)
|
||||
|
||||
return reply.send({ success: true })
|
||||
} catch (err) {
|
||||
console.error("POST /email/accounts error:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// GET SINGLE OR ALL ACCOUNTS
|
||||
// ======================================================================
|
||||
server.get("/email/accounts/:id?", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id?: string }
|
||||
|
||||
// ============================================================
|
||||
// LOAD SINGLE ACCOUNT
|
||||
// ============================================================
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(userCredentials)
|
||||
.where(eq(userCredentials.id, id))
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
const returnData: any = {}
|
||||
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
if (key.endsWith("Encrypted")) {
|
||||
const cleanKey = key.replace("Encrypted", "")
|
||||
// @ts-ignore
|
||||
returnData[cleanKey] = decrypt(val as string)
|
||||
} else {
|
||||
returnData[key] = val
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send(returnData)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LOAD ALL ACCOUNTS FOR TENANT
|
||||
// ============================================================
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(userCredentials)
|
||||
.where(eq(userCredentials.tenantId, req.user.tenant_id))
|
||||
|
||||
const accounts = rows.map(row => {
|
||||
const temp: any = {}
|
||||
console.log(row)
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
console.log(key,val)
|
||||
if (key.endsWith("Encrypted") && val) {
|
||||
// @ts-ignore
|
||||
temp[key.replace("Encrypted", "")] = decrypt(val)
|
||||
} else {
|
||||
temp[key] = val
|
||||
}
|
||||
})
|
||||
return temp
|
||||
})
|
||||
|
||||
return reply.send(accounts)
|
||||
|
||||
} catch (err) {
|
||||
console.error("GET /email/accounts error:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// SEND EMAIL + SAVE IN IMAP SENT FOLDER
|
||||
// ======================================================================
|
||||
server.post("/email/send", async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as {
|
||||
to: string
|
||||
cc?: string
|
||||
bcc?: string
|
||||
subject?: string
|
||||
text?: string
|
||||
html?: string
|
||||
attachments?: any
|
||||
account: string
|
||||
}
|
||||
|
||||
// Fetch email credentials
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(userCredentials)
|
||||
.where(eq(userCredentials.id, body.account))
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return reply.code(404).send({ error: "Account not found" })
|
||||
|
||||
const accountData: any = {}
|
||||
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
if (key.endsWith("Encrypted") && val) {
|
||||
// @ts-ignore
|
||||
accountData[key.replace("Encrypted", "")] = decrypt(val as string)
|
||||
} else {
|
||||
accountData[key] = val
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------
|
||||
// SEND EMAIL VIA SMTP
|
||||
// -------------------------
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: accountData.smtpHost,
|
||||
port: accountData.smtpPort,
|
||||
secure: accountData.smtpSsl,
|
||||
auth: {
|
||||
user: accountData.email,
|
||||
pass: accountData.password,
|
||||
},
|
||||
})
|
||||
|
||||
const message = {
|
||||
from: accountData.email,
|
||||
to: body.to,
|
||||
cc: body.cc,
|
||||
bcc: body.bcc,
|
||||
subject: body.subject,
|
||||
html: body.html,
|
||||
text: body.text,
|
||||
attachments: body.attachments,
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail(message)
|
||||
|
||||
// -------------------------
|
||||
// SAVE TO IMAP SENT FOLDER
|
||||
// -------------------------
|
||||
const imap = new ImapFlow({
|
||||
host: accountData.imapHost,
|
||||
port: accountData.imapPort,
|
||||
secure: accountData.imapSsl,
|
||||
auth: {
|
||||
user: accountData.email,
|
||||
pass: accountData.password,
|
||||
},
|
||||
})
|
||||
|
||||
await imap.connect()
|
||||
|
||||
const mail = new MailComposer(message)
|
||||
const raw = await mail.compile().build()
|
||||
|
||||
for await (const mailbox of await imap.list()) {
|
||||
if (mailbox.specialUse === "\\Sent") {
|
||||
await imap.mailboxOpen(mailbox.path)
|
||||
await imap.append(mailbox.path, raw, ["\\Seen"])
|
||||
await imap.logout()
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ success: true })
|
||||
|
||||
} catch (err) {
|
||||
console.error("POST /email/send error:", err)
|
||||
return reply.code(500).send({ error: "Failed to send email" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
128
backend/src/routes/exports.ts
Normal file
128
backend/src/routes/exports.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import {insertHistoryItem} from "../utils/history";
|
||||
import {buildExportZip} from "../utils/export/datev";
|
||||
import {s3} from "../utils/s3";
|
||||
import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3"
|
||||
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
|
||||
import dayjs from "dayjs";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {secrets} from "../utils/secrets";
|
||||
import {createSEPAExport} from "../utils/export/sepa";
|
||||
|
||||
const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
|
||||
console.log(startDate,endDate,beraternr,mandantennr)
|
||||
|
||||
// 1) ZIP erzeugen
|
||||
const buffer = await buildExportZip(server,req.user.tenant_id, startDate, endDate, beraternr, mandantennr)
|
||||
console.log("ZIP created")
|
||||
console.log(buffer)
|
||||
|
||||
// 2) Dateiname & Key festlegen
|
||||
const fileKey = `${req.user.tenant_id}/exports/Export_${dayjs(startDate).format("YYYY-MM-DD")}_${dayjs(endDate).format("YYYY-MM-DD")}_${randomUUID()}.zip`
|
||||
console.log(fileKey)
|
||||
|
||||
// 3) In S3 hochladen
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
Body: buffer,
|
||||
ContentType: "application/zip",
|
||||
})
|
||||
)
|
||||
|
||||
// 4) Presigned URL erzeugen (24h gültig)
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
}),
|
||||
{ expiresIn: 60 * 60 * 24 }
|
||||
)
|
||||
|
||||
console.log(url)
|
||||
|
||||
// 5) In Supabase-DB speichern
|
||||
const { data, error } = await server.supabase
|
||||
.from("exports")
|
||||
.insert([
|
||||
{
|
||||
tenant_id: req.user.tenant_id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
valid_until: dayjs().add(24,"hours").toISOString(),
|
||||
file_path: fileKey,
|
||||
url: url,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single()
|
||||
|
||||
console.log(data)
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
|
||||
export default async function exportRoutes(server: FastifyInstance) {
|
||||
//Export DATEV
|
||||
server.post("/exports/datev", async (req, reply) => {
|
||||
const { start_date, end_date, beraternr, mandantennr } = req.body as {
|
||||
start_date: string
|
||||
end_date: string
|
||||
beraternr: string
|
||||
mandantennr: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
reply.send({success:true})
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await createDatevExport(server,req,start_date,end_date,beraternr,mandantennr)
|
||||
console.log("Job done ✅")
|
||||
} catch (err) {
|
||||
console.error("Job failed ❌", err)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
server.post("/exports/sepa", async (req, reply) => {
|
||||
const { idsToExport } = req.body as {
|
||||
idsToExport: Array<number>
|
||||
}
|
||||
|
||||
|
||||
|
||||
reply.send({success:true})
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await createSEPAExport(server, idsToExport, req.user.tenant_id)
|
||||
console.log("Job done ✅")
|
||||
} catch (err) {
|
||||
console.error("Job failed ❌", err)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
//List Exports Available for Download
|
||||
|
||||
server.get("/exports", async (req,reply) => {
|
||||
const {data,error} = await server.supabase.from("exports").select().eq("tenant_id",req.user.tenant_id)
|
||||
|
||||
console.log(data,error)
|
||||
reply.send(data)
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
293
backend/src/routes/files.ts
Normal file
293
backend/src/routes/files.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import multipart from "@fastify/multipart"
|
||||
import { s3 } from "../utils/s3"
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand
|
||||
} from "@aws-sdk/client-s3"
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import archiver from "archiver"
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import {
|
||||
files,
|
||||
createddocuments,
|
||||
customers
|
||||
} from "../../db/schema"
|
||||
|
||||
|
||||
export default async function fileRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// MULTIPART INIT
|
||||
// -------------------------------------------------------------
|
||||
await server.register(multipart, {
|
||||
limits: { fileSize: 20 * 1024 * 1024 } // 20 MB
|
||||
})
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UPLOAD FILE
|
||||
// -------------------------------------------------------------
|
||||
server.post("/files/upload", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data: any = await req.file()
|
||||
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
|
||||
const fileBuffer = await data.toBuffer()
|
||||
|
||||
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
|
||||
|
||||
// 1️⃣ DB-Eintrag erzeugen
|
||||
const inserted = await server.db
|
||||
.insert(files)
|
||||
.values({ tenant: tenantId })
|
||||
.returning()
|
||||
|
||||
const created = inserted[0]
|
||||
if (!created) throw new Error("Could not create DB entry")
|
||||
|
||||
// 2️⃣ Datei in S3 speichern
|
||||
const fileKey = `${tenantId}/filesbyid/${created.id}/${data.filename}`
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
Body: fileBuffer,
|
||||
ContentType: data.mimetype
|
||||
}))
|
||||
|
||||
// 3️⃣ DB updaten: meta + path
|
||||
await server.db
|
||||
.update(files)
|
||||
.set({
|
||||
...meta,
|
||||
path: fileKey
|
||||
})
|
||||
.where(eq(files.id, created.id))
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
filename: data.filename,
|
||||
path: fileKey
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Upload failed" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET FILE OR LIST FILES
|
||||
// -------------------------------------------------------------
|
||||
server.get("/files/:id?", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id?: string }
|
||||
|
||||
// 🔹 EINZELNE DATEI
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
// 🔹 ALLE DATEIEN DES TENANTS (mit createddocument + customer)
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const list = await server.db
|
||||
//@ts-ignore
|
||||
.select({
|
||||
...files,
|
||||
createddocument: createddocuments,
|
||||
customer: customers
|
||||
})
|
||||
.from(files)
|
||||
.leftJoin(
|
||||
createddocuments,
|
||||
eq(files.createddocument, createddocuments.id)
|
||||
)
|
||||
.leftJoin(
|
||||
customers,
|
||||
eq(createddocuments.customer, customers.id)
|
||||
)
|
||||
.where(eq(files.tenant, tenantId))
|
||||
|
||||
return { files: list }
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Could not load files" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// DOWNLOAD (SINGLE OR MULTI ZIP)
|
||||
// -------------------------------------------------------------
|
||||
server.post("/files/download/:id?", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id?: string }
|
||||
//@ts-ignore
|
||||
const ids = req.body?.ids || []
|
||||
|
||||
// -------------------------------------------------
|
||||
// 1️⃣ SINGLE DOWNLOAD
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
if (!file) return reply.code(404).send({ error: "File not found" })
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: file.path!
|
||||
})
|
||||
|
||||
const { Body, ContentType } = await s3.send(command)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of Body as any) chunks.push(chunk)
|
||||
const buffer = Buffer.concat(chunks)
|
||||
|
||||
reply.header("Content-Type", ContentType || "application/octet-stream")
|
||||
reply.header("Content-Disposition", `attachment; filename="${file.path?.split("/").pop()}"`)
|
||||
return reply.send(buffer)
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------
|
||||
// 2️⃣ MULTI DOWNLOAD → ZIP
|
||||
// -------------------------------------------------
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(inArray(files.id, ids))
|
||||
|
||||
if (!rows.length) return reply.code(404).send({ error: "Files not found" })
|
||||
|
||||
reply.header("Content-Type", "application/zip")
|
||||
reply.header("Content-Disposition", `attachment; filename="dateien.zip"`)
|
||||
|
||||
const archive = archiver("zip", { zlib: { level: 9 } })
|
||||
|
||||
for (const entry of rows) {
|
||||
const cmd = new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: entry.path!
|
||||
})
|
||||
const { Body } = await s3.send(cmd)
|
||||
|
||||
archive.append(Body as any, {
|
||||
name: entry.path?.split("/").pop() || entry.id
|
||||
})
|
||||
}
|
||||
|
||||
await archive.finalize()
|
||||
return reply.send(archive)
|
||||
}
|
||||
|
||||
return reply.code(400).send({ error: "No id or ids provided" })
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Download failed" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GENERATE PRESIGNED URL(S)
|
||||
// -------------------------------------------------------------
|
||||
server.post("/files/presigned/:id?", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id?: string }
|
||||
const { ids } = req.body as { ids?: string[] }
|
||||
const tenantId = req.user?.tenant_id
|
||||
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
// -------------------------------------------------
|
||||
// SINGLE FILE PRESIGNED URL
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
|
||||
{ expiresIn: 900 }
|
||||
)
|
||||
|
||||
return { ...file, url }
|
||||
} else {
|
||||
// -------------------------------------------------
|
||||
// MULTIPLE PRESIGNED URLs
|
||||
// -------------------------------------------------
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return reply.code(400).send({ error: "No ids provided" })
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.tenant, tenantId))
|
||||
|
||||
const selected = rows.filter(f => ids.includes(f.id) && f.path)
|
||||
|
||||
console.log(selected)
|
||||
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: selected[0].path! }),
|
||||
{ expiresIn: 900 }
|
||||
)
|
||||
console.log(url)
|
||||
console.log(selected.filter(f => !f.path))
|
||||
|
||||
const output = await Promise.all(
|
||||
selected.map(async (file) => {
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
|
||||
{ expiresIn: 900 }
|
||||
)
|
||||
return { ...file, url }
|
||||
})
|
||||
)
|
||||
|
||||
return { files: output }
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Could not create presigned URLs" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
222
backend/src/routes/functions.ts
Normal file
222
backend/src/routes/functions.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
||||
//import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||
import dayjs from "dayjs";
|
||||
//import { ready as zplReady } from 'zpl-renderer-js'
|
||||
//import { renderZPL } from "zpl-image";
|
||||
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat.js";
|
||||
import isoWeek from "dayjs/plugin/isoWeek.js";
|
||||
import isBetween from "dayjs/plugin/isBetween.js";
|
||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
|
||||
import duration from "dayjs/plugin/duration.js";
|
||||
import timezone from "dayjs/plugin/timezone.js";
|
||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||
import {citys} from "../../db/schema";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(isBetween)
|
||||
dayjs.extend(isSameOrAfter)
|
||||
dayjs.extend(isSameOrBefore)
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
export default async function functionRoutes(server: FastifyInstance) {
|
||||
server.post("/functions/pdf/:type", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
data: any
|
||||
backgroundPath?: string
|
||||
}
|
||||
const {type} = req.params as {type:string}
|
||||
|
||||
|
||||
try {
|
||||
|
||||
let pdf = null
|
||||
|
||||
if(type === "createdDocument") {
|
||||
pdf = await createInvoicePDF(
|
||||
server,
|
||||
"base64",
|
||||
body.data,
|
||||
body.backgroundPath
|
||||
)
|
||||
} else if(type === "timesheet") {
|
||||
pdf = await createTimeSheetPDF(
|
||||
server,
|
||||
"base64",
|
||||
body.data,
|
||||
body.backgroundPath
|
||||
)
|
||||
}
|
||||
|
||||
return pdf // Fastify wandelt automatisch in JSON
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
reply.code(500).send({ error: "Failed to create PDF" })
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
|
||||
const { numberrange } = req.params as { numberrange: string };
|
||||
const tenant = (req as any).user.tenant_id
|
||||
|
||||
try {
|
||||
const result = await useNextNumberRangeNumber(server,tenant, numberrange)
|
||||
reply.send(result) // JSON automatisch
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
reply.code(500).send({ error: "Failed to generate next number" })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @route GET /functions/workingtimeevaluation/:user_id
|
||||
* @query start_date=YYYY-MM-DD
|
||||
* @query end_date=YYYY-MM-DD
|
||||
*/
|
||||
server.get("/functions/timeevaluation/:user_id", async (req, reply) => {
|
||||
const { user_id } = req.params as { user_id: string }
|
||||
const { start_date, end_date } = req.query as { start_date: string; end_date: string }
|
||||
const { tenant_id } = req.user
|
||||
|
||||
// 🔒 Sicherheitscheck: andere User nur bei Berechtigung
|
||||
if (user_id !== req.user.user_id && !req.hasPermission("staff.time.read_all")) {
|
||||
return reply.code(403).send({ error: "Not allowed to view other users." })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateTimesEvaluation(server, user_id, tenant_id, start_date, end_date)
|
||||
reply.send(result)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
reply.code(500).send({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
server.get('/functions/check-zip/:zip', async (req, reply) => {
|
||||
const { zip } = req.params as { zip: string }
|
||||
|
||||
if (!zip) {
|
||||
return reply.code(400).send({ error: 'ZIP is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
//@ts-ignore
|
||||
const data = await server.db.select().from(citys).where(eq(citys.zip,zip))
|
||||
|
||||
|
||||
/*const { data, error } = await server.supabase
|
||||
.from('citys')
|
||||
.select()
|
||||
.eq('zip', zip)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
console.log(error)
|
||||
return reply.code(500).send({ error: 'Database error' })
|
||||
}*/
|
||||
|
||||
if (!data) {
|
||||
return reply.code(404).send({ error: 'ZIP not found' })
|
||||
}
|
||||
|
||||
//districtMap
|
||||
const bundeslaender = [
|
||||
{ code: 'DE-BW', name: 'Baden-Württemberg' },
|
||||
{ code: 'DE-BY', name: 'Bayern' },
|
||||
{ code: 'DE-BE', name: 'Berlin' },
|
||||
{ code: 'DE-BB', name: 'Brandenburg' },
|
||||
{ code: 'DE-HB', name: 'Bremen' },
|
||||
{ code: 'DE-HH', name: 'Hamburg' },
|
||||
{ code: 'DE-HE', name: 'Hessen' },
|
||||
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
|
||||
{ code: 'DE-NI', name: 'Niedersachsen' },
|
||||
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
|
||||
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
|
||||
{ code: 'DE-SL', name: 'Saarland' },
|
||||
{ code: 'DE-SN', name: 'Sachsen' },
|
||||
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
|
||||
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
|
||||
{ code: 'DE-TH', name: 'Thüringen' }
|
||||
]
|
||||
|
||||
|
||||
|
||||
return reply.send({
|
||||
...data,
|
||||
//@ts-ignore
|
||||
state_code: bundeslaender.find(i => i.name === data.countryName)
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return reply.code(500).send({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
server.post('/functions/serial/start', async (req, reply) => {
|
||||
console.log(req.body)
|
||||
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
|
||||
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
|
||||
})
|
||||
|
||||
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
||||
const {execution_id} = req.params as { execution_id: string }
|
||||
//@ts-ignore
|
||||
await finishManualGeneration(server,execution_id)
|
||||
})
|
||||
|
||||
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
||||
await server.services.bankStatements.run(req.user.tenant_id);
|
||||
})
|
||||
|
||||
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
|
||||
|
||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
||||
})
|
||||
|
||||
|
||||
/*server.post('/print/zpl/preview', async (req, reply) => {
|
||||
const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}
|
||||
|
||||
console.log(widthMm,heightMm,dpmm)
|
||||
|
||||
if (!zpl) {
|
||||
return reply.code(400).send({ error: 'Missing ZPL string' })
|
||||
}
|
||||
|
||||
try {
|
||||
// 1️⃣ Renderer initialisieren
|
||||
const { api } = await zplReady
|
||||
|
||||
// 2️⃣ Rendern (liefert base64-encoded PNG)
|
||||
const base64Png = await api.zplToBase64Async(zpl, widthMm, heightMm, dpmm)
|
||||
|
||||
return await encodeBase64ToNiimbot(base64Png, 'top')
|
||||
} catch (err) {
|
||||
console.error('[ZPL Preview Error]', err)
|
||||
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
|
||||
}
|
||||
})
|
||||
|
||||
server.post('/print/label', async (req, reply) => {
|
||||
const { context, width=584, heigth=354 } = req.body as {context:any,width:number,heigth:number}
|
||||
|
||||
try {
|
||||
const base64 = await generateLabel(context,width,heigth)
|
||||
|
||||
return {
|
||||
encoded: await encodeBase64ToNiimbot(base64, 'top'),
|
||||
base64: base64
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ZPL Preview Error]', err)
|
||||
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
|
||||
}
|
||||
})*/
|
||||
|
||||
}
|
||||
14
backend/src/routes/health.ts
Normal file
14
backend/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
|
||||
};
|
||||
});
|
||||
}
|
||||
104
backend/src/routes/helpdesk.inbound.email.ts
Normal file
104
backend/src/routes/helpdesk.inbound.email.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// modules/helpdesk/helpdesk.inbound.email.ts
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||
import {extractDomain, findCustomerOrContactByEmailOrDomain} from "../utils/helpers";
|
||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 📧 Interne M2M-Route für eingehende E-Mails
|
||||
// -------------------------------------------------------------
|
||||
|
||||
const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
|
||||
server.post('/helpdesk/inbound-email', async (req, res) => {
|
||||
|
||||
const {
|
||||
tenant_id,
|
||||
channel_id,
|
||||
from,
|
||||
subject,
|
||||
text,
|
||||
message_id,
|
||||
in_reply_to,
|
||||
} = req.body as {
|
||||
tenant_id: number
|
||||
channel_id: string
|
||||
from: {address: string, name: string}
|
||||
subject: string
|
||||
text: string
|
||||
message_id: string
|
||||
in_reply_to: string
|
||||
}
|
||||
|
||||
if (!tenant_id || !from?.address || !text) {
|
||||
return res.status(400).send({ error: 'Invalid payload' })
|
||||
}
|
||||
|
||||
server.log.info(`[InboundEmail] Neue Mail von ${from.address} für Tenant ${tenant_id}`)
|
||||
|
||||
// 1️⃣ Kunde & Kontakt ermitteln
|
||||
const { customer, contact: contactPerson } =
|
||||
(await findCustomerOrContactByEmailOrDomain(server, from.address, tenant_id)) || {}
|
||||
|
||||
// 2️⃣ Kontakt anlegen oder laden
|
||||
const contact = await getOrCreateContact(server, tenant_id, {
|
||||
email: from.address,
|
||||
display_name: from.name || from.address,
|
||||
customer_id: customer,
|
||||
contact_id: contactPerson,
|
||||
})
|
||||
|
||||
// 3️⃣ Konversation anhand In-Reply-To suchen
|
||||
let conversationId: string | null = null
|
||||
if (in_reply_to) {
|
||||
const { data: msg } = await server.supabase
|
||||
.from('helpdesk_messages')
|
||||
.select('conversation_id')
|
||||
.eq('external_message_id', in_reply_to)
|
||||
.maybeSingle()
|
||||
conversationId = msg?.conversation_id || null
|
||||
}
|
||||
|
||||
// 4️⃣ Neue Konversation anlegen falls keine existiert
|
||||
let conversation
|
||||
if (!conversationId) {
|
||||
conversation = await createConversation(server, {
|
||||
tenant_id,
|
||||
contact,
|
||||
channel_instance_id: channel_id,
|
||||
subject: subject || '(kein Betreff)',
|
||||
customer_id: customer,
|
||||
contact_person_id: contactPerson,
|
||||
})
|
||||
conversationId = conversation.id
|
||||
} else {
|
||||
const { data } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.select('*')
|
||||
.eq('id', conversationId)
|
||||
.single()
|
||||
conversation = data
|
||||
}
|
||||
|
||||
// 5️⃣ Nachricht speichern
|
||||
await addMessage(server, {
|
||||
tenant_id,
|
||||
conversation_id: conversationId,
|
||||
direction: 'incoming',
|
||||
payload: { type: 'text', text },
|
||||
external_message_id: message_id,
|
||||
raw_meta: { source: 'email' },
|
||||
})
|
||||
|
||||
server.log.info(`[InboundEmail] Ticket ${conversationId} gespeichert`)
|
||||
|
||||
return res.status(201).send({
|
||||
success: true,
|
||||
conversation_id: conversationId,
|
||||
ticket_number: conversation.ticket_number,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default helpdeskInboundEmailRoutes
|
||||
142
backend/src/routes/helpdesk.inbound.ts
Normal file
142
backend/src/routes/helpdesk.inbound.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// modules/helpdesk/helpdesk.inbound.routes.ts
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||
|
||||
/**
|
||||
* Öffentliche Route zum Empfang eingehender Kontaktformular-Nachrichten.
|
||||
* Authentifizierung: über `public_token` aus helpdesk_channel_instances
|
||||
*/
|
||||
|
||||
function extractDomain(email) {
|
||||
if (!email) return null
|
||||
const parts = email.split("@")
|
||||
return parts.length === 2 ? parts[1].toLowerCase() : null
|
||||
}
|
||||
|
||||
async function findCustomerOrContactByEmailOrDomain(server,fromMail, tenantId) {
|
||||
const sender = fromMail
|
||||
const senderDomain = extractDomain(sender)
|
||||
if (!senderDomain) return null
|
||||
|
||||
|
||||
// 1️⃣ Direkter Match über contacts
|
||||
const { data: contactMatch } = await server.supabase
|
||||
.from("contacts")
|
||||
.select("id, customer")
|
||||
.eq("email", sender)
|
||||
.eq("tenant", tenantId)
|
||||
.maybeSingle()
|
||||
|
||||
if (contactMatch?.customer_id) return {
|
||||
customer: contactMatch.customer,
|
||||
contact: contactMatch.id
|
||||
}
|
||||
|
||||
// 2️⃣ Kunden laden, bei denen E-Mail oder Rechnungsmail passt
|
||||
const { data: customers, error } = await server.supabase
|
||||
.from("customers")
|
||||
.select("id, infoData")
|
||||
.eq("tenant", tenantId)
|
||||
|
||||
if (error) {
|
||||
console.error(`[Helpdesk] Fehler beim Laden der Kunden:`, error.message)
|
||||
return null
|
||||
}
|
||||
|
||||
// 3️⃣ Durch Kunden iterieren und prüfen
|
||||
for (const c of customers || []) {
|
||||
const info = c.infoData || {}
|
||||
const email = info.email?.toLowerCase()
|
||||
const invoiceEmail = info.invoiceEmail?.toLowerCase()
|
||||
|
||||
const emailDomain = extractDomain(email)
|
||||
const invoiceDomain = extractDomain(invoiceEmail)
|
||||
|
||||
// exakter Match oder Domain-Match
|
||||
if (
|
||||
sender === email ||
|
||||
sender === invoiceEmail ||
|
||||
senderDomain === emailDomain ||
|
||||
senderDomain === invoiceDomain
|
||||
) {
|
||||
return {customer: c.id, contact:null}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
|
||||
// Öffentliche POST-Route
|
||||
server.post('/helpdesk/inbound/:public_token', async (req, res) => {
|
||||
const { public_token } = req.params as { public_token: string }
|
||||
const { email, phone, display_name, subject, message } = req.body as {
|
||||
email: string,
|
||||
phone: string,
|
||||
display_name: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).send({ error: 'Message content required' })
|
||||
}
|
||||
|
||||
// 1️⃣ Kanalinstanz anhand des Tokens ermitteln
|
||||
const { data: channel, error: channelError } = await server.supabase
|
||||
.from('helpdesk_channel_instances')
|
||||
.select('*')
|
||||
.eq('public_token', public_token)
|
||||
.single()
|
||||
|
||||
if (channelError || !channel) {
|
||||
return res.status(404).send({ error: 'Invalid channel token' })
|
||||
}
|
||||
|
||||
const tenant_id = channel.tenant_id
|
||||
const channel_instance_id = channel.id
|
||||
|
||||
// @ts-ignore
|
||||
const {customer, contact: contactPerson} = await findCustomerOrContactByEmailOrDomain(server,email, tenant_id )
|
||||
|
||||
|
||||
// 2️⃣ Kontakt finden oder anlegen
|
||||
const contact = await getOrCreateContact(server, tenant_id, {
|
||||
email,
|
||||
phone,
|
||||
display_name,
|
||||
customer_id: customer,
|
||||
contact_id: contactPerson,
|
||||
})
|
||||
|
||||
// 3️⃣ Konversation erstellen
|
||||
const conversation = await createConversation(server, {
|
||||
tenant_id,
|
||||
contact,
|
||||
channel_instance_id,
|
||||
subject: subject ?? 'Kontaktformular Anfrage',
|
||||
customer_id: customer,
|
||||
contact_person_id: contactPerson
|
||||
})
|
||||
|
||||
// 4️⃣ Erste Nachricht hinzufügen
|
||||
await addMessage(server, {
|
||||
tenant_id,
|
||||
conversation_id: conversation.id,
|
||||
direction: 'incoming',
|
||||
payload: { type: 'text', text: message },
|
||||
raw_meta: { source: 'contact_form' },
|
||||
})
|
||||
|
||||
// (optional) Auto-Antwort oder Event hier ergänzen
|
||||
|
||||
return res.status(201).send({
|
||||
success: true,
|
||||
conversation_id: conversation.id,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default helpdeskInboundRoutes
|
||||
331
backend/src/routes/helpdesk.ts
Normal file
331
backend/src/routes/helpdesk.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
// modules/helpdesk/helpdesk.routes.ts
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { createConversation, getConversations, updateConversationStatus } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||
import { addMessage, getMessages } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||
import {decrypt, encrypt} from "../utils/crypt";
|
||||
import nodemailer from "nodemailer"
|
||||
|
||||
const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
||||
// 📩 1. Liste aller Konversationen
|
||||
server.get('/helpdesk/conversations', async (req, res) => {
|
||||
const tenant_id = req.user?.tenant_id
|
||||
if (!tenant_id) return res.status(401).send({ error: 'Unauthorized' })
|
||||
|
||||
const { status } = req.query as {status: string}
|
||||
const conversations = await getConversations(server, tenant_id, { status })
|
||||
return res.send(conversations)
|
||||
})
|
||||
|
||||
// 🆕 2. Neue Konversation erstellen
|
||||
server.post('/helpdesk/conversations', async (req, res) => {
|
||||
const tenant_id = req.user?.tenant_id
|
||||
if (!tenant_id) return res.status(401).send({ error: 'Unauthorized' })
|
||||
|
||||
const { contact, channel_instance_id, subject, message } = req.body as {
|
||||
contact: object
|
||||
channel_instance_id: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
if (!contact || !channel_instance_id) {
|
||||
return res.status(400).send({ error: 'Missing contact or channel_instance_id' })
|
||||
}
|
||||
|
||||
// 1. Konversation erstellen
|
||||
const conversation = await createConversation(server, {
|
||||
tenant_id,
|
||||
contact,
|
||||
channel_instance_id,
|
||||
subject,
|
||||
})
|
||||
|
||||
// 2. Falls erste Nachricht vorhanden → hinzufügen
|
||||
if (message) {
|
||||
await addMessage(server, {
|
||||
tenant_id,
|
||||
conversation_id: conversation.id,
|
||||
direction: 'incoming',
|
||||
payload: { type: 'text', text: message },
|
||||
})
|
||||
}
|
||||
|
||||
return res.status(201).send(conversation)
|
||||
})
|
||||
|
||||
// 🧭 3. Einzelne Konversation abrufen
|
||||
server.get('/helpdesk/conversations/:id', async (req, res) => {
|
||||
const tenant_id = req.user?.tenant_id
|
||||
const {id: conversation_id} = req.params as {id: string}
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.select('*, helpdesk_contacts(*)')
|
||||
.eq('tenant_id', tenant_id)
|
||||
.eq('id', conversation_id)
|
||||
.single()
|
||||
|
||||
if (error) return res.status(404).send({ error: 'Conversation not found' })
|
||||
return res.send(data)
|
||||
})
|
||||
|
||||
// 🔄 4. Konversation Status ändern
|
||||
server.patch('/helpdesk/conversations/:id/status', async (req, res) => {
|
||||
const {id: conversation_id} = req.params as { id: string }
|
||||
const { status } = req.body as { status: string }
|
||||
|
||||
const updated = await updateConversationStatus(server, conversation_id, status)
|
||||
return res.send(updated)
|
||||
})
|
||||
|
||||
// 💬 5. Nachrichten abrufen
|
||||
server.get('/helpdesk/conversations/:id/messages', async (req, res) => {
|
||||
const {id:conversation_id} = req.params as { id: string }
|
||||
const messages = await getMessages(server, conversation_id)
|
||||
return res.send(messages)
|
||||
})
|
||||
|
||||
// 💌 6. Nachricht hinzufügen (z. B. Antwort eines Agents)
|
||||
server.post('/helpdesk/conversations/:id/messages', async (req, res) => {
|
||||
console.log(req.user)
|
||||
const tenant_id = req.user?.tenant_id
|
||||
const author_user_id = req.user?.user_id
|
||||
const {id: conversation_id} = req.params as { id: string }
|
||||
const { text } = req.body as { text: string }
|
||||
|
||||
if (!text) return res.status(400).send({ error: 'Missing message text' })
|
||||
|
||||
const message = await addMessage(server, {
|
||||
tenant_id,
|
||||
conversation_id,
|
||||
author_user_id,
|
||||
direction: 'outgoing',
|
||||
payload: { type: 'text', text },
|
||||
})
|
||||
|
||||
return res.status(201).send(message)
|
||||
})
|
||||
|
||||
// 👤 7. Kontakt suchen oder anlegen
|
||||
server.post('/helpdesk/contacts', async (req, res) => {
|
||||
const tenant_id = req.user?.tenant_id
|
||||
const { email, phone, display_name } = req.body as { email: string; phone: string, display_name: string }
|
||||
|
||||
const contact = await getOrCreateContact(server, tenant_id, { email, phone, display_name })
|
||||
return res.status(201).send(contact)
|
||||
})
|
||||
|
||||
server.post("/helpdesk/channels", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["type_id", "name", "config"],
|
||||
properties: {
|
||||
type_id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
config: { type: "object" },
|
||||
is_active: { type: "boolean", default: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const { type_id, name, config, is_active = true } = req.body as
|
||||
{
|
||||
type_id: string,
|
||||
name: string,
|
||||
config: {
|
||||
imap:{
|
||||
host: string | object,
|
||||
user: string | object,
|
||||
pass: string | object,
|
||||
},
|
||||
smtp:{
|
||||
host: string | object,
|
||||
user: string | object,
|
||||
pass: string | object,
|
||||
}
|
||||
},
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
// 🔒 Tenant aus Auth-Context
|
||||
const tenant_id = req.user?.tenant_id
|
||||
if (!tenant_id) {
|
||||
return reply.status(401).send({ error: "Kein Tenant im Benutzerkontext gefunden." })
|
||||
}
|
||||
|
||||
if (type_id !== "email") {
|
||||
return reply.status(400).send({ error: "Nur Typ 'email' wird aktuell unterstützt." })
|
||||
}
|
||||
|
||||
try {
|
||||
const safeConfig = { ...config }
|
||||
|
||||
// 🔐 IMAP-Daten verschlüsseln
|
||||
if (safeConfig.imap) {
|
||||
if (safeConfig.imap.host)
|
||||
safeConfig.imap.host = encrypt(safeConfig.imap.host)
|
||||
if (safeConfig.imap.user)
|
||||
safeConfig.imap.user = encrypt(safeConfig.imap.user)
|
||||
if (safeConfig.imap.pass)
|
||||
safeConfig.imap.pass = encrypt(safeConfig.imap.pass)
|
||||
}
|
||||
|
||||
// 🔐 SMTP-Daten verschlüsseln
|
||||
if (safeConfig.smtp) {
|
||||
if (safeConfig.smtp.host)
|
||||
safeConfig.smtp.host = encrypt(safeConfig.smtp.host)
|
||||
if (safeConfig.smtp.user)
|
||||
safeConfig.smtp.user = encrypt(safeConfig.smtp.user)
|
||||
if (safeConfig.smtp.pass)
|
||||
safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass)
|
||||
}
|
||||
|
||||
// Speichern in Supabase
|
||||
const { data, error } = await server.supabase
|
||||
.from("helpdesk_channel_instances")
|
||||
.insert({
|
||||
tenant_id,
|
||||
type_id,
|
||||
name,
|
||||
config: safeConfig,
|
||||
is_active,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// sensible Felder aus Response entfernen
|
||||
if (data.config?.imap) {
|
||||
delete data.config.imap.host
|
||||
delete data.config.imap.user
|
||||
delete data.config.imap.pass
|
||||
}
|
||||
if (data.config?.smtp) {
|
||||
delete data.config.smtp.host
|
||||
delete data.config.smtp.user
|
||||
delete data.config.smtp.pass
|
||||
}
|
||||
|
||||
reply.send({
|
||||
message: "E-Mail-Channel erfolgreich erstellt",
|
||||
channel: data,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Fehler bei Channel-Erstellung:", err)
|
||||
reply.status(500).send({ error: err.message })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
server.post("/helpdesk/conversations/:id/reply", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const conversationId = (req.params as any).id
|
||||
const { text } = req.body as { text: string }
|
||||
|
||||
// 🔹 Konversation inkl. Channel + Kontakt laden
|
||||
const { data: conv, error: convErr } = await server.supabase
|
||||
.from("helpdesk_conversations")
|
||||
.select(`
|
||||
id,
|
||||
tenant_id,
|
||||
subject,
|
||||
channel_instance_id,
|
||||
helpdesk_contacts(email),
|
||||
helpdesk_channel_instances(config, name),
|
||||
ticket_number
|
||||
`)
|
||||
.eq("id", conversationId)
|
||||
.single()
|
||||
|
||||
console.log(conv)
|
||||
|
||||
if (convErr || !conv) {
|
||||
reply.status(404).send({ error: "Konversation nicht gefunden" })
|
||||
return
|
||||
}
|
||||
|
||||
const contact = conv.helpdesk_contacts as unknown as {email: string}
|
||||
const channel = conv.helpdesk_channel_instances as unknown as {name: string}
|
||||
|
||||
console.log(contact)
|
||||
if (!contact?.email) {
|
||||
reply.status(400).send({ error: "Kein Empfänger gefunden" })
|
||||
return
|
||||
}
|
||||
|
||||
// 🔐 SMTP-Daten entschlüsseln
|
||||
try {
|
||||
// @ts-ignore
|
||||
const smtp = channel?.config?.smtp
|
||||
const host =
|
||||
typeof smtp.host === "object" ? decrypt(smtp.host) : smtp.host
|
||||
const user =
|
||||
typeof smtp.user === "object" ? decrypt(smtp.user) : smtp.user
|
||||
const pass =
|
||||
typeof smtp.pass === "object" ? decrypt(smtp.pass) : smtp.pass
|
||||
|
||||
// 🔧 Transporter
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: smtp.port || 465,
|
||||
secure: smtp.secure ?? true,
|
||||
auth: { user, pass },
|
||||
})
|
||||
|
||||
// 📩 Mail senden
|
||||
|
||||
const mailOptions = {
|
||||
from: `"${channel?.name}" <${user}>`,
|
||||
to: contact.email,
|
||||
subject: `${conv.ticket_number} | ${conv.subject}` || `${conv.ticket_number} | Antwort vom FEDEO Helpdesk`,
|
||||
text,
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail(mailOptions)
|
||||
console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`)
|
||||
|
||||
// 💾 Nachricht speichern
|
||||
const { error: insertErr } = await server.supabase
|
||||
.from("helpdesk_messages")
|
||||
.insert({
|
||||
tenant_id: conv.tenant_id,
|
||||
conversation_id: conversationId,
|
||||
direction: "outgoing",
|
||||
payload: { type: "text", text },
|
||||
external_message_id: info.messageId,
|
||||
received_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (insertErr) throw insertErr
|
||||
|
||||
// 🔁 Konversation aktualisieren
|
||||
await server.supabase
|
||||
.from("helpdesk_conversations")
|
||||
.update({ last_message_at: new Date().toISOString() })
|
||||
.eq("id", conversationId)
|
||||
|
||||
reply.send({
|
||||
message: "E-Mail erfolgreich gesendet",
|
||||
messageId: info.messageId,
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error("Fehler beim SMTP-Versand:", err)
|
||||
reply.status(500).send({ error: err.message })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
export default helpdeskRoutes
|
||||
156
backend/src/routes/history.ts
Normal file
156
backend/src/routes/history.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// 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(*)")
|
||||
|
||||
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) ? filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] : null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
41
backend/src/routes/internal/devices.ts
Normal file
41
backend/src/routes/internal/devices.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { devices } from "../../../db/schema";
|
||||
|
||||
export default async function deviceRoutes(fastify: FastifyInstance) {
|
||||
fastify.get<{
|
||||
Params: {
|
||||
externalId: string;
|
||||
};
|
||||
}>(
|
||||
"/devices/by-external-id/:externalId",
|
||||
async (request, reply) => {
|
||||
const { externalId } = request.params;
|
||||
|
||||
const device = await fastify.db
|
||||
.select({
|
||||
id: devices.id,
|
||||
name: devices.name,
|
||||
type: devices.type,
|
||||
tenant: devices.tenant,
|
||||
externalId: devices.externalId,
|
||||
created_at: devices.createdAt,
|
||||
})
|
||||
.from(devices)
|
||||
.where(
|
||||
eq(devices.externalId, externalId)
|
||||
|
||||
)
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
if (!device) {
|
||||
return reply.status(404).send({
|
||||
message: "Device not found",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send(device);
|
||||
}
|
||||
);
|
||||
}
|
||||
107
backend/src/routes/internal/tenant.ts
Normal file
107
backend/src/routes/internal/tenant.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
|
||||
import {
|
||||
authTenantUsers,
|
||||
authUsers,
|
||||
authProfiles,
|
||||
tenants
|
||||
} from "../../../db/schema"
|
||||
|
||||
import {and, eq, inArray} from "drizzle-orm"
|
||||
|
||||
|
||||
export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET CURRENT TENANT
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/:id", async (req) => {
|
||||
//@ts-ignore
|
||||
const tenant = (await server.db.select().from(tenants).where(eq(tenants.id,req.params.id)).limit(1))[0]
|
||||
|
||||
return tenant
|
||||
})
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT USERS (auth_users + auth_profiles)
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/users", async (req, reply) => {
|
||||
try {
|
||||
const authUser = req.user
|
||||
if (!authUser) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const tenantId = authUser.tenant_id
|
||||
|
||||
// 1) auth_tenant_users → user_ids
|
||||
const tenantUsers = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||||
|
||||
const userIds = tenantUsers.map(u => u.user_id)
|
||||
|
||||
if (!userIds.length) {
|
||||
return { tenant_id: tenantId, users: [] }
|
||||
}
|
||||
|
||||
// 2) auth_users laden
|
||||
const users = await server.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
|
||||
// 3) auth_profiles pro Tenant laden
|
||||
const profiles = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
|
||||
const combined = users.map(u => {
|
||||
const profile = profiles.find(p => p.user_id === u.id)
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
profile,
|
||||
full_name: profile?.full_name ?? null
|
||||
}
|
||||
})
|
||||
|
||||
return { tenant_id: tenantId, users: combined }
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/users ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT PROFILES
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/:id/profiles", async (req, reply) => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const tenantId = req.params.id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.tenant_id, tenantId))
|
||||
|
||||
return data
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/profiles ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
122
backend/src/routes/internal/time.ts
Normal file
122
backend/src/routes/internal/time.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
stafftimeentries,
|
||||
stafftimenetryconnects
|
||||
} from "../../../db/schema"
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
desc
|
||||
} from "drizzle-orm"
|
||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||
import {z} from "zod";
|
||||
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||
|
||||
export default async function staffTimeRoutesInternal(server: FastifyInstance) {
|
||||
|
||||
|
||||
server.post("/staff/time/event", async (req, reply) => {
|
||||
try {
|
||||
|
||||
const body = req.body as {user_id:string,tenant_id:number,eventtime:string,eventtype:string}
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
if (!val) return null
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const dataToInsert = {
|
||||
tenant_id: body.tenant_id,
|
||||
user_id: body.user_id,
|
||||
actortype: "user",
|
||||
actoruser_id: body.user_id,
|
||||
eventtime: normalizeDate(body.eventtime),
|
||||
eventtype: body.eventtype,
|
||||
source: "WEB"
|
||||
}
|
||||
|
||||
console.log(dataToInsert)
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(dataToInsert)
|
||||
.returning()
|
||||
|
||||
return created
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
return reply.code(400).send({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// GET /api/staff/time/spans
|
||||
server.get("/staff/time/spans", async (req, reply) => {
|
||||
try {
|
||||
|
||||
// Query-Parameter: targetUserId ist optional
|
||||
const { targetUserId, tenantId} = req.query as { targetUserId: string, tenantId:number };
|
||||
|
||||
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
|
||||
const evaluatedUserId = targetUserId;
|
||||
|
||||
// 💡 "Unendlicher" Zeitraum, wie gewünscht
|
||||
const startDate = new Date(0); // 1970
|
||||
const endDate = new Date("2100-12-31");
|
||||
|
||||
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
|
||||
const allEventsInTimeFrame = await loadValidEvents(
|
||||
server,
|
||||
tenantId,
|
||||
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// SCHRITT 2: Filtere faktische Events
|
||||
const FACTUAL_EVENT_TYPES = new Set([
|
||||
"work_start", "work_end", "pause_start", "pause_end",
|
||||
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
||||
"overtime_compensation_start", "overtime_compensation_end",
|
||||
"auto_stop"
|
||||
]);
|
||||
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
||||
|
||||
// SCHRITT 3: Hole administrative Events
|
||||
const factualEventIds = factualEvents.map(e => e.id);
|
||||
|
||||
if (factualEventIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
||||
|
||||
// SCHRITT 4: Kombinieren und Sortieren
|
||||
const combinedEvents = [
|
||||
...factualEvents,
|
||||
...relatedAdminEvents,
|
||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||
|
||||
// SCHRITT 5: Spans ableiten
|
||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||
|
||||
// SCHRITT 6: Spans anreichern
|
||||
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
||||
|
||||
return enrichedSpans;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden der Spans:", error);
|
||||
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
30
backend/src/routes/notifications.ts
Normal file
30
backend/src/routes/notifications.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// routes/notifications.routes.ts
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { NotificationService, UserDirectory } from '../modules/notification.service';
|
||||
|
||||
// Beispiel: E-Mail aus eigener User-Tabelle laden
|
||||
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
|
||||
const { data, error } = await server.supabase
|
||||
.from('auth_users')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
if (error || !data) return null;
|
||||
return { email: data.email };
|
||||
};
|
||||
|
||||
export default async function notificationsRoutes(server: FastifyInstance) {
|
||||
// wichtig: server.supabase ist über app verfügbar
|
||||
|
||||
const svc = new NotificationService(server, getUserDirectory);
|
||||
|
||||
server.post('/notifications/trigger', async (req, reply) => {
|
||||
try {
|
||||
const res = await svc.trigger(req.body as any);
|
||||
reply.send(res);
|
||||
} catch (err: any) {
|
||||
server.log.error(err);
|
||||
reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
120
backend/src/routes/profiles.ts
Normal file
120
backend/src/routes/profiles.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
import {
|
||||
authProfiles,
|
||||
} from "../../db/schema";
|
||||
|
||||
export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET SINGLE PROFILE
|
||||
// -------------------------------------------------------------
|
||||
server.get("/profiles/:id", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string };
|
||||
const tenantId = (req.user as any)?.tenant_id;
|
||||
|
||||
if (!tenantId) {
|
||||
return reply.code(400).send({ error: "No tenant selected" });
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.id, id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows.length) {
|
||||
return reply.code(404).send({ error: "User not found or not in tenant" });
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
|
||||
} catch (error) {
|
||||
console.error("GET /profiles/:id ERROR:", error);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
function sanitizeProfileUpdate(body: any) {
|
||||
const cleaned: any = { ...body }
|
||||
|
||||
// ❌ Systemfelder entfernen
|
||||
const forbidden = [
|
||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||
"updatedAt", "updatedBy", "old_profile_id", "full_name"
|
||||
]
|
||||
forbidden.forEach(f => delete cleaned[f])
|
||||
|
||||
// ❌ Falls NULL Strings vorkommen → in null umwandeln
|
||||
for (const key of Object.keys(cleaned)) {
|
||||
if (cleaned[key] === "") cleaned[key] = null
|
||||
}
|
||||
|
||||
// ✅ Date-Felder sauber konvertieren, falls vorhanden
|
||||
const dateFields = ["birthday", "entry_date"]
|
||||
|
||||
for (const field of dateFields) {
|
||||
if (cleaned[field]) {
|
||||
const d = new Date(cleaned[field])
|
||||
if (!isNaN(d.getTime())) cleaned[field] = d
|
||||
else delete cleaned[field] // invalid → entfernen
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UPDATE PROFILE
|
||||
// -------------------------------------------------------------
|
||||
server.put("/profiles/:id", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
|
||||
if (!tenantId) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
let body = req.body as any
|
||||
|
||||
// Clean + Normalize
|
||||
body = sanitizeProfileUpdate(body)
|
||||
|
||||
const updateData = {
|
||||
...body,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
}
|
||||
|
||||
const updated = await server.db
|
||||
.update(authProfiles)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.id, id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated.length) {
|
||||
return reply.code(404).send({ error: "User not found or not in tenant" })
|
||||
}
|
||||
|
||||
return updated[0]
|
||||
|
||||
} catch (err) {
|
||||
console.error("PUT /profiles/:id ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
41
backend/src/routes/publiclinks/publiclinks-authenticated.ts
Normal file
41
backend/src/routes/publiclinks/publiclinks-authenticated.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||
|
||||
|
||||
export default async function publiclinksAuthenticatedRoutes(server: FastifyInstance) {
|
||||
server.post("/publiclinks", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = 21; // Hardcoded für Test, später: req.user.tenantId
|
||||
|
||||
const { name, isProtected, pin, customToken, config, defaultProfileId } = req.body as { name:string, isProtected:boolean, pin:string, customToken:string, config:Object, defaultProfileId:string};
|
||||
|
||||
const newLink = await publicLinkService.createLink(server, tenantId,
|
||||
name,
|
||||
isProtected,
|
||||
pin,
|
||||
customToken,
|
||||
config,
|
||||
defaultProfileId);
|
||||
|
||||
return reply.code(201).send({
|
||||
success: true,
|
||||
data: {
|
||||
id: newLink.id,
|
||||
token: newLink.token,
|
||||
fullUrl: `/public/${newLink.token}`, // Helper für Frontend
|
||||
isProtected: newLink.isProtected
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
server.log.error(error);
|
||||
|
||||
// Einfache Fehlerbehandlung
|
||||
if (error.message.includes("bereits vergeben")) {
|
||||
return reply.code(409).send({ error: error.message });
|
||||
}
|
||||
|
||||
return reply.code(500).send({ error: "Fehler beim Erstellen des Links", details: error.message });
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||
|
||||
|
||||
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
||||
server.get("/workflows/context/:token", async (req, reply) => {
|
||||
const { token } = req.params as { token: string };
|
||||
|
||||
// Wir lesen die PIN aus dem Header (Best Practice für Security)
|
||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||
|
||||
try {
|
||||
const context = await publicLinkService.getLinkContext(server, token, pin);
|
||||
|
||||
return reply.send(context);
|
||||
|
||||
} catch (error: any) {
|
||||
// Spezifische Fehlercodes für das Frontend
|
||||
if (error.message === "Link_NotFound") {
|
||||
return reply.code(404).send({ error: "Link nicht gefunden oder abgelaufen" });
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Required") {
|
||||
return reply.code(401).send({
|
||||
error: "PIN erforderlich",
|
||||
code: "PIN_REQUIRED",
|
||||
requirePin: true
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Invalid") {
|
||||
return reply.code(403).send({
|
||||
error: "PIN falsch",
|
||||
code: "PIN_INVALID",
|
||||
requirePin: true
|
||||
});
|
||||
}
|
||||
|
||||
server.log.error(error);
|
||||
return reply.code(500).send({ error: "Interner Server Fehler" });
|
||||
}
|
||||
});
|
||||
|
||||
server.post("/workflows/submit/:token", async (req, reply) => {
|
||||
const { token } = req.params as { token: string };
|
||||
// PIN sicher aus dem Header lesen
|
||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||
// Der Body enthält { profile, project, service, ... }
|
||||
const payload = req.body;
|
||||
|
||||
console.log(payload)
|
||||
|
||||
try {
|
||||
// Service aufrufen (führt die 3 Schritte aus: Lieferschein -> Zeit -> History)
|
||||
const result = await publicLinkService.submitFormData(server, token, payload, pin);
|
||||
|
||||
// 201 Created zurückgeben
|
||||
return reply.code(201).send(result);
|
||||
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
|
||||
// Fehler-Mapping für saubere HTTP Codes
|
||||
if (error.message === "Link_NotFound") {
|
||||
return reply.code(404).send({ error: "Link ungültig oder nicht aktiv" });
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Required") {
|
||||
return reply.code(401).send({ error: "PIN erforderlich" });
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Invalid") {
|
||||
return reply.code(403).send({ error: "PIN ist falsch" });
|
||||
}
|
||||
|
||||
if (error.message === "Profile_Missing") {
|
||||
return reply.code(400).send({ error: "Kein Mitarbeiter-Profil gefunden (weder im Link noch in der Eingabe)" });
|
||||
}
|
||||
|
||||
if (error.message === "Project not found" || error.message === "Service not found") {
|
||||
return reply.code(400).send({ error: "Ausgewähltes Projekt oder Leistung existiert nicht mehr." });
|
||||
}
|
||||
|
||||
// Fallback für alle anderen Fehler (z.B. DB Constraints)
|
||||
return reply.code(500).send({
|
||||
error: "Interner Fehler beim Speichern",
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
555
backend/src/routes/resources/main.ts
Normal file
555
backend/src/routes/resources/main.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
eq,
|
||||
ilike,
|
||||
asc,
|
||||
desc,
|
||||
and,
|
||||
count,
|
||||
inArray,
|
||||
or
|
||||
} from "drizzle-orm"
|
||||
|
||||
|
||||
|
||||
import {resourceConfig} from "../../utils/resource.config";
|
||||
import {useNextNumberRangeNumber} from "../../utils/functions";
|
||||
import {stafftimeentries} from "../../../db/schema";
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// SQL Volltextsuche auf mehreren Feldern
|
||||
// -------------------------------------------------------------
|
||||
|
||||
|
||||
function buildSearchCondition(table: any, columns: string[], search: string) {
|
||||
if (!search || !columns.length) return null
|
||||
|
||||
const term = `%${search.toLowerCase()}%`
|
||||
|
||||
const conditions = columns
|
||||
.map((colName) => table[colName])
|
||||
.filter(Boolean)
|
||||
.map((col) => ilike(col, term))
|
||||
|
||||
if (conditions.length === 0) return null
|
||||
|
||||
// @ts-ignore
|
||||
return or(...conditions)
|
||||
}
|
||||
|
||||
export default async function resourceRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// LIST
|
||||
// -------------------------------------------------------------
|
||||
server.get("/resource/:resource", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId)
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
|
||||
const { search, sort, asc: ascQuery } = req.query as {
|
||||
search?: string
|
||||
sort?: string
|
||||
asc?: string
|
||||
}
|
||||
|
||||
const {resource} = req.params as {resource: string}
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
// WHERE-Basis
|
||||
let whereCond: any = eq(table.tenant, tenantId)
|
||||
|
||||
// 🔍 SQL Search
|
||||
if(search) {
|
||||
const searchCond = buildSearchCondition(
|
||||
table,
|
||||
resourceConfig[resource].searchColumns,
|
||||
search.trim()
|
||||
)
|
||||
|
||||
if (searchCond) {
|
||||
whereCond = and(whereCond, searchCond)
|
||||
}
|
||||
}
|
||||
|
||||
// Base Query
|
||||
let q = server.db.select().from(table).where(whereCond)
|
||||
|
||||
// Sortierung
|
||||
if (sort) {
|
||||
const col = (table as any)[sort]
|
||||
if (col) {
|
||||
//@ts-ignore
|
||||
q = ascQuery === "true"
|
||||
? q.orderBy(asc(col))
|
||||
: q.orderBy(desc(col))
|
||||
}
|
||||
}
|
||||
|
||||
const queryData = await q
|
||||
|
||||
// RELATION LOADING (MANY-TO-ONE)
|
||||
|
||||
let ids = {}
|
||||
let lists = {}
|
||||
let maps = {}
|
||||
let data = [...queryData]
|
||||
|
||||
if(resourceConfig[resource].mtoLoad) {
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
ids[relation] = [...new Set(queryData.map(r => r[relation]).filter(Boolean))];
|
||||
})
|
||||
|
||||
for await (const relation of resourceConfig[resource].mtoLoad ) {
|
||||
console.log(relation)
|
||||
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
|
||||
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
|
||||
})
|
||||
|
||||
data = queryData.map(row => {
|
||||
let toReturn = {
|
||||
...row
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
|
||||
})
|
||||
|
||||
return toReturn
|
||||
});
|
||||
}
|
||||
|
||||
if(resourceConfig[resource].mtmListLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtmListLoad) {
|
||||
console.log(relation)
|
||||
console.log(resource.substring(0,resource.length-1))
|
||||
|
||||
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
|
||||
|
||||
console.log(relationRows.length)
|
||||
|
||||
data = data.map(row => {
|
||||
let toReturn = {
|
||||
...row
|
||||
}
|
||||
|
||||
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
|
||||
|
||||
return toReturn
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
} catch (err) {
|
||||
console.error("ERROR /resource/:resource", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// PAGINATED LIST
|
||||
// -------------------------------------------------------------
|
||||
server.get("/resource/:resource/paginated", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id;
|
||||
if (!tenantId) {
|
||||
return reply.code(400).send({ error: "No tenant selected" });
|
||||
}
|
||||
|
||||
const {resource} = req.params as {resource: string};
|
||||
|
||||
const {queryConfig} = req;
|
||||
const {
|
||||
pagination,
|
||||
sort,
|
||||
filters,
|
||||
paginationDisabled
|
||||
} = queryConfig;
|
||||
|
||||
const { search, distinctColumns } = req.query as {
|
||||
search?: string;
|
||||
distinctColumns?: string;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
let table = resourceConfig[resource].table
|
||||
|
||||
|
||||
let whereCond: any = eq(table.tenant, tenantId);
|
||||
|
||||
|
||||
if(search) {
|
||||
const searchCond = buildSearchCondition(
|
||||
table,
|
||||
resourceConfig[resource].searchColumns,
|
||||
search.trim()
|
||||
)
|
||||
|
||||
if (searchCond) {
|
||||
whereCond = and(whereCond, searchCond)
|
||||
}
|
||||
}
|
||||
|
||||
if (filters) {
|
||||
for (const [key, val] of Object.entries(filters)) {
|
||||
const col = (table as any)[key];
|
||||
if (!col) continue;
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
whereCond = and(whereCond, inArray(col, val));
|
||||
} else {
|
||||
whereCond = and(whereCond, eq(col, val as any));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------
|
||||
// COUNT (for pagination)
|
||||
// -----------------------------------------------
|
||||
const totalRes = await server.db
|
||||
.select({ value: count(table.id) })
|
||||
.from(table)
|
||||
.where(whereCond);
|
||||
|
||||
const total = Number(totalRes[0]?.value ?? 0);
|
||||
|
||||
// -----------------------------------------------
|
||||
// DISTINCT VALUES (regardless of pagination)
|
||||
// -----------------------------------------------
|
||||
const distinctValues: Record<string, any[]> = {};
|
||||
|
||||
if (distinctColumns) {
|
||||
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
|
||||
const col = (table as any)[colName];
|
||||
if (!col) continue;
|
||||
|
||||
const rows = await server.db
|
||||
.select({ v: col })
|
||||
.from(table)
|
||||
.where(eq(table.tenant, tenantId));
|
||||
|
||||
const values = rows
|
||||
.map(r => r.v)
|
||||
.filter(v => v != null && v !== "");
|
||||
|
||||
distinctValues[colName] = [...new Set(values)].sort();
|
||||
}
|
||||
}
|
||||
|
||||
// PAGINATION
|
||||
const offset = pagination?.offset ?? 0;
|
||||
const limit = pagination?.limit ?? 100;
|
||||
|
||||
// SORTING
|
||||
let orderField: any = null;
|
||||
let direction: "asc" | "desc" = "asc";
|
||||
|
||||
if (sort?.length > 0) {
|
||||
const s = sort[0];
|
||||
const col = (table as any)[s.field];
|
||||
if (col) {
|
||||
orderField = col;
|
||||
direction = s.direction === "asc" ? "asc" : "desc";
|
||||
}
|
||||
}
|
||||
|
||||
// MAIN QUERY (Paginated)
|
||||
let q = server.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(whereCond)
|
||||
.offset(offset)
|
||||
.limit(limit);
|
||||
|
||||
if (orderField) {
|
||||
//@ts-ignore
|
||||
q = direction === "asc"
|
||||
? q.orderBy(asc(orderField))
|
||||
: q.orderBy(desc(orderField));
|
||||
}
|
||||
|
||||
const rows = await q;
|
||||
|
||||
if (!rows.length) {
|
||||
return {
|
||||
data: [],
|
||||
queryConfig: {
|
||||
...queryConfig,
|
||||
total,
|
||||
totalPages: 0,
|
||||
distinctValues
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
let data = [...rows]
|
||||
//Many to One
|
||||
if(resourceConfig[resource].mtoLoad) {
|
||||
let ids = {}
|
||||
let lists = {}
|
||||
let maps = {}
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
ids[relation] = [...new Set(rows.map(r => r[relation]).filter(Boolean))];
|
||||
})
|
||||
|
||||
for await (const relation of resourceConfig[resource].mtoLoad ) {
|
||||
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
|
||||
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
|
||||
})
|
||||
|
||||
data = rows.map(row => {
|
||||
let toReturn = {
|
||||
...row
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
|
||||
})
|
||||
|
||||
return toReturn
|
||||
});
|
||||
}
|
||||
|
||||
if(resourceConfig[resource].mtmListLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtmListLoad) {
|
||||
console.log(relation)
|
||||
|
||||
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
|
||||
|
||||
console.log(relationRows)
|
||||
|
||||
data = data.map(row => {
|
||||
let toReturn = {
|
||||
...row
|
||||
}
|
||||
|
||||
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
|
||||
|
||||
return toReturn
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------
|
||||
// RETURN DATA
|
||||
// -----------------------------------------------
|
||||
return {
|
||||
data,
|
||||
queryConfig: {
|
||||
...queryConfig,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
distinctValues
|
||||
}
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error(`ERROR /resource/:resource/paginated:`, err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// DETAIL (mit JOINS)
|
||||
// -------------------------------------------------------------
|
||||
server.get("/resource/:resource/:id/:no_relations?", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string }
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
||||
|
||||
const {resource, no_relations} = req.params as { resource: string, no_relations?: boolean }
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
const projRows = await server.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!projRows.length)
|
||||
return reply.code(404).send({ error: "Resource not found" })
|
||||
|
||||
// ------------------------------------
|
||||
// LOAD RELATIONS
|
||||
// ------------------------------------
|
||||
|
||||
let ids = {}
|
||||
let lists = {}
|
||||
let maps = {}
|
||||
let data = {
|
||||
...projRows[0]
|
||||
}
|
||||
|
||||
if(!no_relations) {
|
||||
if(resourceConfig[resource].mtoLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtoLoad ) {
|
||||
if(data[relation]) {
|
||||
data[relation] = (await server.db.select().from(resourceConfig[relation + "s"].table).where(eq(resourceConfig[relation + "s"].table.id, data[relation])))[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(resourceConfig[resource].mtmLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtmLoad ) {
|
||||
console.log(relation)
|
||||
data[relation] = await server.db.select().from(resourceConfig[relation].table).where(eq(resourceConfig[relation].table[resource.substring(0,resource.length - 1)],id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return data
|
||||
|
||||
} catch (err) {
|
||||
console.error("ERROR /resource/projects/:id", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
// Create
|
||||
server.post("/resource/:resource", async (req, reply) => {
|
||||
try {
|
||||
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 table = resourceConfig[resource].table
|
||||
|
||||
let createData = {
|
||||
...body,
|
||||
tenant: req.user.tenant_id,
|
||||
archived: false, // Standardwert
|
||||
}
|
||||
|
||||
console.log(resourceConfig[resource].numberRangeHolder)
|
||||
|
||||
if (resourceConfig[resource].numberRangeHolder && !body[resourceConfig[resource]]) {
|
||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource)
|
||||
console.log(result)
|
||||
createData[resourceConfig[resource].numberRangeHolder] = result.usedNumber
|
||||
}
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
Object.keys(createData).forEach((key) => {
|
||||
if(key.toLowerCase().includes("date")) createData[key] = normalizeDate(createData[key])
|
||||
})
|
||||
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(table)
|
||||
.values(createData)
|
||||
.returning()
|
||||
|
||||
|
||||
/*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 created;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
reply.status(500)
|
||||
}
|
||||
});
|
||||
|
||||
// UPDATE (inkl. Soft-Delete/Archive)
|
||||
server.put("/resource/:resource/:id", async (req, reply) => {
|
||||
try {
|
||||
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"})
|
||||
}
|
||||
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
//TODO: HISTORY
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
let data = {...body, updated_at: new Date().toISOString(), updated_by: userId}
|
||||
|
||||
Object.keys(data).forEach((key) => {
|
||||
if(key.includes("_at") || key.includes("At")) {
|
||||
data[key] = normalizeDate(data[key])
|
||||
}
|
||||
})
|
||||
|
||||
console.log(data)
|
||||
|
||||
const [updated] = await server.db
|
||||
.update(table)
|
||||
.set(data)
|
||||
.where(and(
|
||||
eq(table.id, id),
|
||||
eq(table.tenant, tenantId)))
|
||||
.returning()
|
||||
|
||||
//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 updated
|
||||
} catch (err) {
|
||||
console.log("ERROR /resource/projects/:id", err)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
75
backend/src/routes/resourcesSpecial.ts
Normal file
75
backend/src/routes/resourcesSpecial.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import { sortData } from "../utils/sort"
|
||||
|
||||
// Schema imports
|
||||
import { accounts, units,countrys } from "../../db/schema"
|
||||
|
||||
const TABLE_MAP: Record<string, any> = {
|
||||
accounts,
|
||||
units,
|
||||
countrys,
|
||||
}
|
||||
|
||||
export default async function resourceRoutesSpecial(server: FastifyInstance) {
|
||||
|
||||
server.get("/resource-special/:resource", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { resource } = req.params as { resource: string }
|
||||
|
||||
// ❌ Wenn falsche Ressource
|
||||
if (!TABLE_MAP[resource]) {
|
||||
return reply.code(400).send({ error: "Invalid special resource" })
|
||||
}
|
||||
|
||||
const table = TABLE_MAP[resource]
|
||||
|
||||
const { select, sort, asc: ascQuery } = req.query as {
|
||||
select?: string
|
||||
sort?: string
|
||||
asc?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// 📌 SELECT: wir ignorieren select string (wie Supabase)
|
||||
// Drizzle kann kein dynamisches Select aus String!
|
||||
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
|
||||
// ---------------------------------------
|
||||
|
||||
let query = server.db.select().from(table)
|
||||
|
||||
// ---------------------------------------
|
||||
// 📌 Sortierung
|
||||
// ---------------------------------------
|
||||
if (sort) {
|
||||
const col = (table as any)[sort]
|
||||
if (col) {
|
||||
//@ts-ignore
|
||||
query =
|
||||
ascQuery === "true"
|
||||
? query.orderBy(asc(col))
|
||||
: query.orderBy(desc(col))
|
||||
}
|
||||
}
|
||||
|
||||
const data = await query
|
||||
|
||||
// Falls sort clientseitig wie früher notwendig ist:
|
||||
const sorted = sortData(
|
||||
data,
|
||||
sort,
|
||||
ascQuery === "true"
|
||||
)
|
||||
|
||||
return sorted
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
430
backend/src/routes/staff/time.ts
Normal file
430
backend/src/routes/staff/time.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
stafftimeentries,
|
||||
stafftimenetryconnects
|
||||
} from "../../../db/schema"
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
desc
|
||||
} from "drizzle-orm"
|
||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||
import {z} from "zod";
|
||||
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||
|
||||
export default async function staffTimeRoutes(server: FastifyInstance) {
|
||||
|
||||
|
||||
server.post("/staff/time/event", async (req, reply) => {
|
||||
try {
|
||||
const userId = req.user.user_id
|
||||
const tenantId = req.user.tenant_id
|
||||
|
||||
const body = req.body as any
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
if (!val) return null
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const dataToInsert = {
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
actortype: "user",
|
||||
actoruser_id: userId,
|
||||
eventtime: normalizeDate(body.eventtime),
|
||||
eventtype: body.eventtype,
|
||||
source: "WEB",
|
||||
payload: body.payload // Payload (z.B. Description) mit speichern
|
||||
}
|
||||
|
||||
console.log(dataToInsert)
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(dataToInsert)
|
||||
.returning()
|
||||
|
||||
return created
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
return reply.code(400).send({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 🆕 POST /staff/time/edit (Bearbeiten durch Invalidieren + Neu erstellen)
|
||||
server.post("/staff/time/edit", async (req, reply) => {
|
||||
try {
|
||||
const userId = req.user.user_id;
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
// Wir erwarten das komplette Paket für die Änderung
|
||||
const {
|
||||
originalEventIds, // Array der IDs, die "gelöscht" werden sollen (Start ID, End ID)
|
||||
newStart, // ISO String
|
||||
newEnd, // ISO String
|
||||
newType, // z.B. 'work', 'vacation'
|
||||
description,
|
||||
reason // Warum wurde geändert? (Audit)
|
||||
} = req.body as {
|
||||
originalEventIds: string[],
|
||||
newStart: string,
|
||||
newEnd: string | null,
|
||||
newType: string,
|
||||
description?: string,
|
||||
reason?: string
|
||||
};
|
||||
|
||||
if (!originalEventIds || originalEventIds.length === 0) {
|
||||
return reply.code(400).send({ error: "Keine Events zum Bearbeiten angegeben." });
|
||||
}
|
||||
|
||||
// 1. Transaction starten (damit alles oder nichts passiert)
|
||||
await server.db.transaction(async (tx) => {
|
||||
|
||||
// A. INVALIDIEREN (Die alten Events "löschen")
|
||||
// Wir erstellen für jedes alte Event ein 'invalidated' Event
|
||||
const invalidations = originalEventIds.map(id => ({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId, // Gehört dem Mitarbeiter
|
||||
actortype: "user",
|
||||
actoruser_id: userId, // Wer hat geändert?
|
||||
eventtime: new Date(),
|
||||
eventtype: "invalidated", // <--- NEUER TYP: Muss in loadValidEvents gefiltert werden!
|
||||
source: "WEB",
|
||||
related_event_id: id, // Zeigt auf das alte Event
|
||||
metadata: {
|
||||
reason: reason || "Bearbeitung",
|
||||
replaced_by_edit: true
|
||||
}
|
||||
}));
|
||||
|
||||
// Batch Insert
|
||||
// @ts-ignore
|
||||
await tx.insert(stafftimeevents).values(invalidations);
|
||||
|
||||
// B. NEU ERSTELLEN (Die korrigierten Events anlegen)
|
||||
|
||||
// Start Event
|
||||
// @ts-ignore
|
||||
await tx.insert(stafftimeevents).values({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
actortype: "user",
|
||||
actoruser_id: userId,
|
||||
eventtime: new Date(newStart),
|
||||
eventtype: `${newType}_start`, // z.B. work_start
|
||||
source: "WEB",
|
||||
payload: { description: description || "" }
|
||||
});
|
||||
|
||||
// End Event (nur wenn vorhanden)
|
||||
if (newEnd) {
|
||||
// @ts-ignore
|
||||
await tx.insert(stafftimeevents).values({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
actortype: "user",
|
||||
actoruser_id: userId,
|
||||
eventtime: new Date(newEnd),
|
||||
eventtype: `${newType}_end`, // z.B. work_end
|
||||
source: "WEB"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Fehler beim Bearbeiten:", err);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /staff/time/submit
|
||||
server.post("/staff/time/submit", async (req, reply) => {
|
||||
try {
|
||||
const userId = req.user.user_id; // Mitarbeiter, der einreicht
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
// Erwartet eine Liste von IDs der faktischen Events (work_start, work_end, etc.)
|
||||
const { eventIds } = req.body as { eventIds: string[] };
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return reply.code(400).send({ error: "Keine Events zum Einreichen angegeben." });
|
||||
}
|
||||
|
||||
const inserts = eventIds.map((eventId) => ({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId, // Event gehört zum Mitarbeiter
|
||||
actortype: "user",
|
||||
actoruser_id: userId, // Mitarbeiter ist der Akteur
|
||||
eventtime: new Date(),
|
||||
eventtype: "submitted", // NEU: Event-Typ für Einreichung
|
||||
source: "WEB",
|
||||
related_event_id: eventId, // Verweis auf das faktische Event
|
||||
}));
|
||||
|
||||
const createdEvents = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(inserts)
|
||||
.returning();
|
||||
|
||||
return { submittedCount: createdEvents.length };
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /staff/time/approve
|
||||
server.post("/staff/time/approve", async (req, reply) => {
|
||||
try {
|
||||
// 🚨 Berechtigungsprüfung (Voraussetzung: req.user enthält Manager-Status)
|
||||
/*if (!req.user.isManager) {
|
||||
return reply.code(403).send({ error: "Keine Genehmigungsberechtigung." });
|
||||
}*/
|
||||
|
||||
const actorId = req.user.user_id; // Manager ist der Akteur
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
const { eventIds, employeeUserId } = req.body as {
|
||||
eventIds: string[];
|
||||
employeeUserId: string; // Die ID des Mitarbeiters, dessen Zeit genehmigt wird
|
||||
};
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return reply.code(400).send({ error: "Keine Events zur Genehmigung angegeben." });
|
||||
}
|
||||
|
||||
const inserts = eventIds.map((eventId) => ({
|
||||
tenant_id: tenantId,
|
||||
user_id: employeeUserId, // Event gehört zum Mitarbeiter
|
||||
actortype: "user",
|
||||
actoruser_id: actorId, // Manager ist der Akteur
|
||||
eventtime: new Date(),
|
||||
eventtype: "approved", // NEU: Event-Typ für Genehmigung
|
||||
source: "WEB",
|
||||
related_event_id: eventId, // Verweis auf das faktische Event
|
||||
metadata: {
|
||||
// Optional: Genehmigungskommentar
|
||||
approvedBy: req.user.email
|
||||
}
|
||||
}));
|
||||
|
||||
const createdEvents = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(inserts)
|
||||
.returning();
|
||||
|
||||
return { approvedCount: createdEvents.length };
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /staff/time/reject
|
||||
server.post("/staff/time/reject", async (req, reply) => {
|
||||
try {
|
||||
// 🚨 Berechtigungsprüfung
|
||||
/*if (!req.user.isManager) {
|
||||
return reply.code(403).send({ error: "Keine Zurückweisungsberechtigung." });
|
||||
}*/
|
||||
|
||||
const actorId = req.user.user_id; // Manager ist der Akteur
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
const { eventIds, employeeUserId, reason } = req.body as {
|
||||
eventIds: string[];
|
||||
employeeUserId: string;
|
||||
reason?: string; // Optionaler Grund für die Ablehnung
|
||||
};
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return reply.code(400).send({ error: "Keine Events zur Ablehnung angegeben." });
|
||||
}
|
||||
|
||||
const inserts = eventIds.map((eventId) => ({
|
||||
tenant_id: tenantId,
|
||||
user_id: employeeUserId, // Event gehört zum Mitarbeiter
|
||||
actortype: "user",
|
||||
actoruser_id: actorId, // Manager ist der Akteur
|
||||
eventtime: new Date(),
|
||||
eventtype: "rejected", // NEU: Event-Typ für Ablehnung
|
||||
source: "WEB",
|
||||
related_event_id: eventId, // Verweis auf das faktische Event
|
||||
metadata: {
|
||||
reason: reason || "Ohne Angabe"
|
||||
}
|
||||
}));
|
||||
|
||||
const createdEvents = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(inserts)
|
||||
.returning();
|
||||
|
||||
return { rejectedCount: createdEvents.length };
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/staff/time/spans
|
||||
server.get("/staff/time/spans", async (req, reply) => {
|
||||
try {
|
||||
// Der eingeloggte User (Anfragesteller)
|
||||
const actingUserId = req.user.user_id;
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
// Query-Parameter: targetUserId ist optional
|
||||
const { targetUserId } = req.query as { targetUserId?: string };
|
||||
|
||||
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
|
||||
const evaluatedUserId = targetUserId || actingUserId;
|
||||
|
||||
// 💡 "Unendlicher" Zeitraum, wie gewünscht
|
||||
const startDate = new Date(0); // 1970
|
||||
const endDate = new Date("2100-12-31");
|
||||
|
||||
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
|
||||
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
|
||||
const allEventsInTimeFrame = await loadValidEvents(
|
||||
server,
|
||||
tenantId,
|
||||
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// SCHRITT 2: Filtere faktische Events
|
||||
const FACTUAL_EVENT_TYPES = new Set([
|
||||
"work_start", "work_end", "pause_start", "pause_end",
|
||||
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
||||
"overtime_compensation_start", "overtime_compensation_end",
|
||||
"auto_stop"
|
||||
]);
|
||||
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
||||
|
||||
// SCHRITT 3: Hole administrative Events
|
||||
const factualEventIds = factualEvents.map(e => e.id);
|
||||
|
||||
if (factualEventIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
||||
|
||||
// SCHRITT 4: Kombinieren und Sortieren
|
||||
const combinedEvents = [
|
||||
...factualEvents,
|
||||
...relatedAdminEvents,
|
||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||
|
||||
// SCHRITT 5: Spans ableiten
|
||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||
|
||||
// SCHRITT 6: Spans anreichern
|
||||
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
||||
|
||||
return enrichedSpans;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden der Spans:", error);
|
||||
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
|
||||
}
|
||||
});
|
||||
|
||||
server.get("/staff/time/evaluation", async (req, reply) => {
|
||||
try {
|
||||
// --- 1. Eingangsdaten und Validierung des aktuellen Nutzers ---
|
||||
|
||||
// Daten des aktuell eingeloggten (anfragenden) Benutzers
|
||||
const actingUserId = req.user.user_id;
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
// Query-Parameter extrahieren
|
||||
const { from, to, targetUserId } = req.query as {
|
||||
from: string,
|
||||
to: string,
|
||||
targetUserId?: string // Optionale ID des Benutzers, dessen Daten abgerufen werden sollen
|
||||
};
|
||||
|
||||
// Die ID, für die die Auswertung tatsächlich durchgeführt wird
|
||||
const evaluatedUserId = targetUserId || actingUserId;
|
||||
|
||||
const startDate = new Date(from);
|
||||
const endDate = new Date(to);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return reply.code(400).send({ error: "Ungültiges Datumsformat." });
|
||||
}
|
||||
|
||||
// --- 3. Ausführung der Logik für den ermittelten Benutzer ---
|
||||
|
||||
// SCHRITT 1: Lade ALLE gültigen Events im Zeitraum
|
||||
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
|
||||
const allEventsInTimeFrame = await loadValidEvents(
|
||||
server, tenantId, evaluatedUserId, startDate, endDate // Verwendung der evaluatedUserId
|
||||
);
|
||||
|
||||
// 1b: Trenne Faktische und Administrative Events
|
||||
const FACTUAL_EVENT_TYPES = new Set([
|
||||
"work_start", "work_end", "pause_start", "pause_end",
|
||||
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
||||
"overtime_compensation_start", "overtime_compensation_end",
|
||||
"auto_stop"
|
||||
]);
|
||||
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
||||
|
||||
// 1c: Sammle alle IDs der faktischen Events im Zeitraum
|
||||
const factualEventIds = factualEvents.map(e => e.id);
|
||||
|
||||
// SCHRITT 2: Lade die administrativen Events, die sich auf diese IDs beziehen (auch NACH dem Zeitraum)
|
||||
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
||||
|
||||
// SCHRITT 3: Kombiniere alle Events für die Weiterverarbeitung
|
||||
const combinedEvents = [
|
||||
...factualEvents,
|
||||
...relatedAdminEvents,
|
||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||
|
||||
// SCHRITT 4: Ableiten und Anreichern
|
||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
||||
|
||||
// SCHRITT 5: Erstellung der finalen Auswertung (Summen und Salden)
|
||||
const evaluationSummary = await buildTimeEvaluationFromSpans(
|
||||
server,
|
||||
evaluatedUserId, // Verwendung der evaluatedUserId
|
||||
tenantId,
|
||||
from,
|
||||
to,
|
||||
enrichedSpans
|
||||
);
|
||||
|
||||
return {
|
||||
userId: evaluatedUserId, // Rückgabe der ID, für die ausgewertet wurde
|
||||
spans: enrichedSpans,
|
||||
summary: evaluationSummary
|
||||
};
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error("Fehler in /staff/time/evaluation:", error);
|
||||
return reply.code(500).send({ error: "Interner Serverfehler bei der Zeitauswertung." });
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
71
backend/src/routes/staff/timeconnects.ts
Normal file
71
backend/src/routes/staff/timeconnects.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { StaffTimeEntryConnect } from '../../types/staff'
|
||||
|
||||
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
||||
|
||||
// ▶ Connect anlegen
|
||||
server.post<{ Params: { id: string }, Body: Omit<StaffTimeEntryConnect, 'id' | 'time_entry_id'> }>(
|
||||
'/staff/time/:id/connects',
|
||||
async (req, reply) => {
|
||||
const { id } = req.params
|
||||
const { started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes } = req.body
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from('staff_time_entry_connects')
|
||||
.insert([{ time_entry_id: id, started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes }])
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return reply.code(400).send({ error: error.message })
|
||||
return reply.send(data)
|
||||
}
|
||||
)
|
||||
|
||||
// ▶ Connects abrufen
|
||||
server.get<{ Params: { id: string } }>(
|
||||
'/staff/time/:id/connects',
|
||||
async (req, reply) => {
|
||||
const { id } = req.params
|
||||
const { data, error } = await server.supabase
|
||||
.from('staff_time_entry_connects')
|
||||
.select('*')
|
||||
.eq('time_entry_id', id)
|
||||
.order('started_at', { ascending: true })
|
||||
|
||||
if (error) return reply.code(400).send({ error: error.message })
|
||||
return reply.send(data)
|
||||
}
|
||||
)
|
||||
|
||||
// ▶ Connect aktualisieren
|
||||
server.patch<{ Params: { connectId: string }, Body: Partial<StaffTimeEntryConnect> }>(
|
||||
'/staff/time/connects/:connectId',
|
||||
async (req, reply) => {
|
||||
const { connectId } = req.params
|
||||
const { data, error } = await server.supabase
|
||||
.from('staff_time_entry_connects')
|
||||
.update({ ...req.body, updated_at: new Date().toISOString() })
|
||||
.eq('id', connectId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return reply.code(400).send({ error: error.message })
|
||||
return reply.send(data)
|
||||
}
|
||||
)
|
||||
|
||||
// ▶ Connect löschen
|
||||
server.delete<{ Params: { connectId: string } }>(
|
||||
'/staff/time/connects/:connectId',
|
||||
async (req, reply) => {
|
||||
const { connectId } = req.params
|
||||
const { error } = await server.supabase
|
||||
.from('staff_time_entry_connects')
|
||||
.delete()
|
||||
.eq('id', connectId)
|
||||
|
||||
if (error) return reply.code(400).send({ error: error.message })
|
||||
return reply.send({ success: true })
|
||||
}
|
||||
)
|
||||
}
|
||||
244
backend/src/routes/tenant.ts
Normal file
244
backend/src/routes/tenant.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
import {
|
||||
authTenantUsers,
|
||||
authUsers,
|
||||
authProfiles,
|
||||
tenants
|
||||
} from "../../db/schema"
|
||||
|
||||
import {and, eq, inArray} from "drizzle-orm"
|
||||
|
||||
|
||||
export default async function tenantRoutes(server: FastifyInstance) {
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET CURRENT TENANT
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant", async (req) => {
|
||||
if (req.tenant) {
|
||||
return {
|
||||
message: `Hallo vom Tenant ${req.tenant?.name}`,
|
||||
tenant_id: req.tenant?.id,
|
||||
}
|
||||
}
|
||||
return {
|
||||
message: "Server ist im MultiTenant-Modus – es werden alle verfügbaren Tenants geladen."
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// SWITCH TENANT
|
||||
// -------------------------------------------------------------
|
||||
server.post("/tenant/switch", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const { tenant_id } = req.body as { tenant_id: string }
|
||||
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
|
||||
|
||||
// prüfen ob der User zu diesem Tenant gehört
|
||||
const membership = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(and(
|
||||
eq(authTenantUsers.user_id, req.user.user_id),
|
||||
eq(authTenantUsers.tenant_id, Number(tenant_id))
|
||||
))
|
||||
|
||||
if (!membership.length) {
|
||||
return reply.code(403).send({ error: "Not a member of this tenant" })
|
||||
}
|
||||
|
||||
// JWT neu erzeugen
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: req.user.user_id,
|
||||
email: req.user.email,
|
||||
tenant_id,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: "6h" }
|
||||
)
|
||||
|
||||
reply.setCookie("token", token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 3,
|
||||
})
|
||||
|
||||
return { token }
|
||||
|
||||
} catch (err) {
|
||||
console.error("TENANT SWITCH ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT USERS (auth_users + auth_profiles)
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/users", async (req, reply) => {
|
||||
try {
|
||||
const authUser = req.user
|
||||
if (!authUser) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const tenantId = authUser.tenant_id
|
||||
|
||||
// 1) auth_tenant_users → user_ids
|
||||
const tenantUsers = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||||
|
||||
const userIds = tenantUsers.map(u => u.user_id)
|
||||
|
||||
if (!userIds.length) {
|
||||
return { tenant_id: tenantId, users: [] }
|
||||
}
|
||||
|
||||
// 2) auth_users laden
|
||||
const users = await server.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
|
||||
// 3) auth_profiles pro Tenant laden
|
||||
const profiles = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
|
||||
const combined = users.map(u => {
|
||||
const profile = profiles.find(p => p.user_id === u.id)
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
profile,
|
||||
full_name: profile?.full_name ?? null
|
||||
}
|
||||
})
|
||||
|
||||
return { tenant_id: tenantId, users: combined }
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/users ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT PROFILES
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/profiles", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.tenant_id, tenantId))
|
||||
|
||||
return { data }
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/profiles ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UPDATE NUMBER RANGE
|
||||
// -------------------------------------------------------------
|
||||
server.put("/tenant/numberrange/:numberrange", async (req, reply) => {
|
||||
try {
|
||||
const user = req.user
|
||||
if (!user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { numberrange } = req.params as { numberrange: string }
|
||||
const { numberRange } = req.body as { numberRange: any }
|
||||
|
||||
if (!numberRange) {
|
||||
return reply.code(400).send({ error: "numberRange required" })
|
||||
}
|
||||
|
||||
const tenantId = Number(user.tenant_id)
|
||||
|
||||
const currentTenantRows = await server.db
|
||||
.select()
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
|
||||
const current = currentTenantRows[0]
|
||||
if (!current) return reply.code(404).send({ error: "Tenant not found" })
|
||||
|
||||
const updatedRanges = {
|
||||
//@ts-ignore
|
||||
...current.numberRanges,
|
||||
[numberrange]: numberRange
|
||||
}
|
||||
|
||||
const updated = await server.db
|
||||
.update(tenants)
|
||||
.set({ numberRanges: updatedRanges })
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.returning()
|
||||
|
||||
return updated[0]
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/numberrange ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UPDATE TENANT OTHER FIELDS
|
||||
// -------------------------------------------------------------
|
||||
server.put("/tenant/other/:id", async (req, reply) => {
|
||||
try {
|
||||
const user = req.user
|
||||
if (!user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const { data } = req.body as { data: any }
|
||||
|
||||
if (!data) return reply.code(400).send({ error: "data required" })
|
||||
|
||||
const updated = await server.db
|
||||
.update(tenants)
|
||||
.set(data)
|
||||
.where(eq(tenants.id, Number(user.tenant_id)))
|
||||
.returning()
|
||||
|
||||
return updated[0]
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/other ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user