Added Backend

This commit is contained in:
2026-01-06 12:07:43 +01:00
parent b013ef8f4b
commit 6f3d4c0bff
165 changed files with 0 additions and 0 deletions

117
backend/src/routes/admin.ts Normal file
View 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" });
}
});
}

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

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

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

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

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

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

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

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

View 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' })
}
})*/
}

View File

@@ -0,0 +1,14 @@
import { FastifyInstance } from "fastify";
export default async function routes(server: FastifyInstance) {
server.get("/ping", async () => {
// Testquery gegen DB
const { data, error } = await server.supabase.from("tenants").select("id").limit(1);
return {
status: "ok",
db: error ? "not connected" : "connected",
tenant_count: data?.length ?? 0
};
});
}

View 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

View 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

View 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

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

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

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

View 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." });
}
});
}

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

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

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

View File

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

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

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

View 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." });
}
});
}

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

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