Added Backend
This commit is contained in:
166
backend/src/index.ts
Normal file
166
backend/src/index.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import Fastify from "fastify";
|
||||
import swaggerPlugin from "./plugins/swagger"
|
||||
import supabasePlugin from "./plugins/supabase";
|
||||
import dayjsPlugin from "./plugins/dayjs";
|
||||
import healthRoutes from "./routes/health";
|
||||
import meRoutes from "./routes/auth/me";
|
||||
import tenantRoutes from "./routes/tenant";
|
||||
import tenantPlugin from "./plugins/tenant";
|
||||
import authRoutes from "./routes/auth/auth";
|
||||
import authRoutesAuthenticated from "./routes/auth/auth-authenticated";
|
||||
import authPlugin from "./plugins/auth";
|
||||
import adminRoutes from "./routes/admin";
|
||||
import corsPlugin from "./plugins/cors";
|
||||
import queryConfigPlugin from "./plugins/queryconfig";
|
||||
import dbPlugin from "./plugins/db";
|
||||
import resourceRoutesSpecial from "./routes/resourcesSpecial";
|
||||
import fastifyCookie from "@fastify/cookie";
|
||||
import historyRoutes from "./routes/history";
|
||||
import fileRoutes from "./routes/files";
|
||||
import functionRoutes from "./routes/functions";
|
||||
import bankingRoutes from "./routes/banking";
|
||||
import exportRoutes from "./routes/exports"
|
||||
import emailAsUserRoutes from "./routes/emailAsUser";
|
||||
import authProfilesRoutes from "./routes/profiles";
|
||||
import helpdeskRoutes from "./routes/helpdesk";
|
||||
import helpdeskInboundRoutes from "./routes/helpdesk.inbound";
|
||||
import notificationsRoutes from "./routes/notifications";
|
||||
import staffTimeRoutes from "./routes/staff/time";
|
||||
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
||||
import userRoutes from "./routes/auth/user";
|
||||
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||
|
||||
//Public Links
|
||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||
|
||||
//Resources
|
||||
import resourceRoutes from "./routes/resources/main";
|
||||
|
||||
//M2M
|
||||
import authM2m from "./plugins/auth.m2m";
|
||||
import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email";
|
||||
import deviceRoutes from "./routes/internal/devices";
|
||||
import tenantRoutesInternal from "./routes/internal/tenant";
|
||||
import staffTimeRoutesInternal from "./routes/internal/time";
|
||||
|
||||
//Devices
|
||||
import devicesRFIDRoutes from "./routes/devices/rfid";
|
||||
|
||||
|
||||
import {sendMail} from "./utils/mailer";
|
||||
import {loadSecrets, secrets} from "./utils/secrets";
|
||||
import {initMailer} from "./utils/mailer"
|
||||
import {initS3} from "./utils/s3";
|
||||
|
||||
//Services
|
||||
import servicesPlugin from "./plugins/services";
|
||||
|
||||
async function main() {
|
||||
const app = Fastify({ logger: false });
|
||||
await loadSecrets();
|
||||
await initMailer();
|
||||
await initS3();
|
||||
|
||||
|
||||
|
||||
/*app.addHook("onRequest", (req, reply, done) => {
|
||||
console.log("Incoming:", req.method, req.url, "Headers:", req.headers)
|
||||
done()
|
||||
})*/
|
||||
|
||||
// Plugins Global verfügbar
|
||||
await app.register(swaggerPlugin);
|
||||
await app.register(corsPlugin);
|
||||
await app.register(supabasePlugin);
|
||||
await app.register(tenantPlugin);
|
||||
await app.register(dayjsPlugin);
|
||||
await app.register(dbPlugin);
|
||||
await app.register(servicesPlugin);
|
||||
|
||||
app.addHook('preHandler', (req, reply, done) => {
|
||||
console.log(req.method)
|
||||
console.log('Matched path:', req.routeOptions.url)
|
||||
console.log('Exact URL:', req.url)
|
||||
done()
|
||||
})
|
||||
|
||||
app.get('/health', async (req, res) => {
|
||||
return res.send({ status: 'ok' })
|
||||
})
|
||||
|
||||
//Plugin nur auf bestimmten Routes
|
||||
await app.register(queryConfigPlugin, {
|
||||
routes: ['/api/resource/:resource/paginated']
|
||||
})
|
||||
|
||||
app.register(fastifyCookie, {
|
||||
secret: secrets.COOKIE_SECRET,
|
||||
})
|
||||
// Öffentliche Routes
|
||||
await app.register(authRoutes);
|
||||
await app.register(healthRoutes);
|
||||
|
||||
await app.register(helpdeskInboundRoutes);
|
||||
|
||||
await app.register(publiclinksNonAuthenticatedRoutes)
|
||||
|
||||
|
||||
await app.register(async (m2mApp) => {
|
||||
await m2mApp.register(authM2m)
|
||||
await m2mApp.register(helpdeskInboundEmailRoutes)
|
||||
await m2mApp.register(deviceRoutes)
|
||||
await m2mApp.register(tenantRoutesInternal)
|
||||
await m2mApp.register(staffTimeRoutesInternal)
|
||||
},{prefix: "/internal"})
|
||||
|
||||
await app.register(async (devicesApp) => {
|
||||
await devicesApp.register(devicesRFIDRoutes)
|
||||
},{prefix: "/devices"})
|
||||
|
||||
|
||||
//Geschützte Routes
|
||||
|
||||
await app.register(async (subApp) => {
|
||||
await subApp.register(authPlugin);
|
||||
await subApp.register(authRoutesAuthenticated);
|
||||
await subApp.register(meRoutes);
|
||||
await subApp.register(tenantRoutes);
|
||||
await subApp.register(adminRoutes);
|
||||
await subApp.register(resourceRoutesSpecial);
|
||||
await subApp.register(historyRoutes);
|
||||
await subApp.register(fileRoutes);
|
||||
await subApp.register(functionRoutes);
|
||||
await subApp.register(bankingRoutes);
|
||||
await subApp.register(exportRoutes);
|
||||
await subApp.register(emailAsUserRoutes);
|
||||
await subApp.register(authProfilesRoutes);
|
||||
await subApp.register(helpdeskRoutes);
|
||||
await subApp.register(notificationsRoutes);
|
||||
await subApp.register(staffTimeRoutes);
|
||||
await subApp.register(staffTimeConnectRoutes);
|
||||
await subApp.register(userRoutes);
|
||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||
await subApp.register(resourceRoutes);
|
||||
|
||||
},{prefix: "/api"})
|
||||
|
||||
app.ready(async () => {
|
||||
try {
|
||||
const result = await app.db.execute("SELECT NOW()");
|
||||
console.log("✓ DB connection OK: " + JSON.stringify(result.rows[0]));
|
||||
} catch (err) {
|
||||
console.log("❌ DB connection failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Start
|
||||
try {
|
||||
await app.listen({ port: secrets.PORT, host: secrets.HOST });
|
||||
console.log(`🚀 Server läuft auf http://${secrets.HOST}:${secrets.PORT}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
253
backend/src/modules/cron/bankstatementsync.service.ts
Normal file
253
backend/src/modules/cron/bankstatementsync.service.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
// /services/bankStatementService.ts
|
||||
import axios from "axios"
|
||||
import dayjs from "dayjs"
|
||||
import utc from "dayjs/plugin/utc.js"
|
||||
import {secrets} from "../../utils/secrets"
|
||||
import {FastifyInstance} from "fastify"
|
||||
|
||||
// Drizzle imports
|
||||
import {
|
||||
bankaccounts,
|
||||
bankstatements,
|
||||
} from "../../../db/schema"
|
||||
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
isNull,
|
||||
} from "drizzle-orm"
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
||||
interface BalanceAmount {
|
||||
amount: string
|
||||
currency: string
|
||||
}
|
||||
|
||||
interface BookedTransaction {
|
||||
bookingDate: string
|
||||
valueDate: string
|
||||
internalTransactionId: string
|
||||
transactionAmount: { amount: string; currency: string }
|
||||
|
||||
creditorAccount?: { iban?: string }
|
||||
creditorName?: string
|
||||
|
||||
debtorAccount?: { iban?: string }
|
||||
debtorName?: string
|
||||
|
||||
remittanceInformationUnstructured?: string
|
||||
remittanceInformationStructured?: string
|
||||
remittanceInformationStructuredArray?: string[]
|
||||
additionalInformation?: string
|
||||
}
|
||||
|
||||
interface TransactionsResponse {
|
||||
transactions: {
|
||||
booked: BookedTransaction[]
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
if (!val) return null
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
export function bankStatementService(server: FastifyInstance) {
|
||||
|
||||
let accessToken: string | null = null
|
||||
|
||||
// -----------------------------------------------
|
||||
// ✔ TOKEN LADEN
|
||||
// -----------------------------------------------
|
||||
const getToken = async () => {
|
||||
console.log("Fetching GoCardless token…")
|
||||
|
||||
const response = await axios.post(
|
||||
`${secrets.GOCARDLESS_BASE_URL}/token/new/`,
|
||||
{
|
||||
secret_id: secrets.GOCARDLESS_SECRET_ID,
|
||||
secret_key: secrets.GOCARDLESS_SECRET_KEY,
|
||||
}
|
||||
)
|
||||
|
||||
accessToken = response.data.access
|
||||
}
|
||||
|
||||
// -----------------------------------------------
|
||||
// ✔ Salden laden
|
||||
// -----------------------------------------------
|
||||
const getBalanceData = async (accountId: string): Promise<any | false> => {
|
||||
try {
|
||||
const {data} = await axios.get(
|
||||
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/balances`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
server.log.error(err.response?.data ?? err)
|
||||
|
||||
const expired =
|
||||
err.response?.data?.summary?.includes("expired") ||
|
||||
err.response?.data?.detail?.includes("expired")
|
||||
|
||||
if (expired) {
|
||||
await server.db
|
||||
.update(bankaccounts)
|
||||
.set({expired: true})
|
||||
.where(eq(bankaccounts.accountId, accountId))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------
|
||||
// ✔ Transaktionen laden
|
||||
// -----------------------------------------------
|
||||
const getTransactionData = async (accountId: string) => {
|
||||
try {
|
||||
const {data} = await axios.get<TransactionsResponse>(
|
||||
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/transactions`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return data.transactions.booked
|
||||
} catch (err: any) {
|
||||
server.log.error(err.response?.data ?? err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------
|
||||
// ✔ Haupt-Sync-Prozess
|
||||
// -----------------------------------------------
|
||||
const syncAccounts = async (tenantId:number) => {
|
||||
try {
|
||||
console.log("Starting account sync…")
|
||||
|
||||
// 🟦 DB: Aktive Accounts
|
||||
const accounts = await server.db
|
||||
.select()
|
||||
.from(bankaccounts)
|
||||
.where(and(eq(bankaccounts.expired, false),eq(bankaccounts.tenant, tenantId)))
|
||||
|
||||
if (!accounts.length) return
|
||||
|
||||
const allNewTransactions: any[] = []
|
||||
|
||||
for (const account of accounts) {
|
||||
|
||||
// ---------------------------
|
||||
// 1. BALANCE SYNC
|
||||
// ---------------------------
|
||||
const balData = await getBalanceData(account.accountId)
|
||||
|
||||
if (balData === false) break
|
||||
|
||||
if (balData) {
|
||||
const closing = balData.balances.find(
|
||||
(i: any) => i.balanceType === "closingBooked"
|
||||
)
|
||||
|
||||
const bookedBal = Number(closing.balanceAmount.amount)
|
||||
|
||||
await server.db
|
||||
.update(bankaccounts)
|
||||
.set({balance: bookedBal})
|
||||
.where(eq(bankaccounts.id, account.id))
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// 2. TRANSACTIONS
|
||||
// ---------------------------
|
||||
let transactions = await getTransactionData(account.accountId)
|
||||
if (!transactions) continue
|
||||
|
||||
//@ts-ignore
|
||||
transactions = transactions.map((item) => ({
|
||||
account: account.id,
|
||||
date: normalizeDate(item.bookingDate),
|
||||
credIban: item.creditorAccount?.iban ?? null,
|
||||
credName: item.creditorName ?? null,
|
||||
text: `
|
||||
${item.remittanceInformationUnstructured ?? ""}
|
||||
${item.remittanceInformationStructured ?? ""}
|
||||
${item.additionalInformation ?? ""}
|
||||
${item.remittanceInformationStructuredArray?.join("") ?? ""}
|
||||
`.trim(),
|
||||
amount: Number(item.transactionAmount.amount),
|
||||
tenant: account.tenant,
|
||||
debIban: item.debtorAccount?.iban ?? null,
|
||||
debName: item.debtorName ?? null,
|
||||
gocardlessId: item.internalTransactionId,
|
||||
currency: item.transactionAmount.currency,
|
||||
valueDate: normalizeDate(item.valueDate),
|
||||
}))
|
||||
|
||||
// Existierende Statements laden
|
||||
const existing = await server.db
|
||||
.select({gocardlessId: bankstatements.gocardlessId})
|
||||
.from(bankstatements)
|
||||
.where(eq(bankstatements.tenant, account.tenant))
|
||||
|
||||
const filtered = transactions.filter(
|
||||
//@ts-ignore
|
||||
(tx) => !existing.some((x) => x.gocardlessId === tx.gocardlessId)
|
||||
)
|
||||
|
||||
allNewTransactions.push(...filtered)
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// 3. NEW TRANSACTIONS → DB
|
||||
// ---------------------------
|
||||
if (allNewTransactions.length > 0) {
|
||||
await server.db.insert(bankstatements).values(allNewTransactions)
|
||||
|
||||
const affectedAccounts = [
|
||||
...new Set(allNewTransactions.map((t) => t.account)),
|
||||
]
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
if (!val) return null
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
for (const accId of affectedAccounts) {
|
||||
await server.db
|
||||
.update(bankaccounts)
|
||||
//@ts-ignore
|
||||
.set({syncedAt: normalizeDate(dayjs())})
|
||||
.where(eq(bankaccounts.id, accId))
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Bank statement sync completed.")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
run: async (tenant) => {
|
||||
await getToken()
|
||||
await syncAccounts(tenant)
|
||||
console.log("Service: Bankstatement sync finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
259
backend/src/modules/cron/dokuboximport.service.ts
Normal file
259
backend/src/modules/cron/dokuboximport.service.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import axios from "axios"
|
||||
import dayjs from "dayjs"
|
||||
import { ImapFlow } from "imapflow"
|
||||
import { simpleParser } from "mailparser"
|
||||
import { FastifyInstance } from "fastify"
|
||||
|
||||
import {saveFile} from "../../utils/files";
|
||||
import { secrets } from "../../utils/secrets"
|
||||
|
||||
// Drizzle Imports
|
||||
import {
|
||||
tenants,
|
||||
folders,
|
||||
filetags,
|
||||
} from "../../../db/schema"
|
||||
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
} from "drizzle-orm"
|
||||
|
||||
let badMessageDetected = false
|
||||
let badMessageMessageSent = false
|
||||
|
||||
let client: ImapFlow | null = null
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// IMAP CLIENT INITIALIZEN
|
||||
// -------------------------------------------------------------
|
||||
export async function initDokuboxClient() {
|
||||
client = new ImapFlow({
|
||||
host: secrets.DOKUBOX_IMAP_HOST,
|
||||
port: secrets.DOKUBOX_IMAP_PORT,
|
||||
secure: secrets.DOKUBOX_IMAP_SECURE,
|
||||
auth: {
|
||||
user: secrets.DOKUBOX_IMAP_USER,
|
||||
pass: secrets.DOKUBOX_IMAP_PASSWORD
|
||||
},
|
||||
logger: false
|
||||
})
|
||||
|
||||
console.log("Dokubox E-Mail Client Initialized")
|
||||
|
||||
await client.connect()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// MAIN SYNC FUNCTION (DRIZZLE VERSION)
|
||||
// -------------------------------------------------------------
|
||||
export const syncDokubox = (server: FastifyInstance) =>
|
||||
async () => {
|
||||
|
||||
console.log("Perform Dokubox Sync")
|
||||
|
||||
await initDokuboxClient()
|
||||
|
||||
if (!client?.usable) {
|
||||
throw new Error("E-Mail Client not usable")
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// TENANTS LADEN (DRIZZLE)
|
||||
// -------------------------------
|
||||
const tenantList = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
emailAddresses: tenants.dokuboxEmailAddresses,
|
||||
key: tenants.dokuboxkey
|
||||
})
|
||||
.from(tenants)
|
||||
|
||||
const lock = await client.getMailboxLock("INBOX")
|
||||
|
||||
try {
|
||||
|
||||
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
|
||||
|
||||
const parsed = await simpleParser(msg.source)
|
||||
|
||||
const message = {
|
||||
id: msg.uid,
|
||||
subject: parsed.subject,
|
||||
to: parsed.to?.value || [],
|
||||
cc: parsed.cc?.value || [],
|
||||
attachments: parsed.attachments || []
|
||||
}
|
||||
|
||||
// -------------------------------------------------
|
||||
// MAPPING / FIND TENANT
|
||||
// -------------------------------------------------
|
||||
const config = await getMessageConfigDrizzle(server, message, tenantList)
|
||||
|
||||
if (!config) {
|
||||
badMessageDetected = true
|
||||
|
||||
if (!badMessageMessageSent) {
|
||||
badMessageMessageSent = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.attachments.length > 0) {
|
||||
for (const attachment of message.attachments) {
|
||||
await saveFile(
|
||||
server,
|
||||
config.tenant,
|
||||
message.id,
|
||||
attachment,
|
||||
config.folder,
|
||||
config.filetype
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!badMessageDetected) {
|
||||
badMessageDetected = false
|
||||
badMessageMessageSent = false
|
||||
}
|
||||
|
||||
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
|
||||
await client.messageDelete({ seen: true })
|
||||
|
||||
} finally {
|
||||
lock.release()
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT ERKENNEN + FOLDER/FILETYPES (DRIZZLE VERSION)
|
||||
// -------------------------------------------------------------
|
||||
const getMessageConfigDrizzle = async (
|
||||
server: FastifyInstance,
|
||||
message,
|
||||
tenantsList: any[]
|
||||
) => {
|
||||
|
||||
let possibleKeys: string[] = []
|
||||
|
||||
if (message.to) {
|
||||
message.to.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
if (message.cc) {
|
||||
message.cc.forEach((item) =>
|
||||
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// TENANT IDENTIFY
|
||||
// -------------------------------------------
|
||||
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
|
||||
|
||||
if (!tenant && message.to?.length) {
|
||||
const address = message.to[0].address.toLowerCase()
|
||||
|
||||
tenant = tenantsList.find((t) =>
|
||||
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
|
||||
)
|
||||
}
|
||||
|
||||
if (!tenant) return null
|
||||
|
||||
// -------------------------------------------
|
||||
// FOLDER + FILETYPE VIA SUBJECT
|
||||
// -------------------------------------------
|
||||
let folderId = null
|
||||
let filetypeId = null
|
||||
|
||||
// -------------------------------------------
|
||||
// Rechnung / Invoice
|
||||
// -------------------------------------------
|
||||
if (message.subject?.match(/(Rechnung|Beleg|Invoice|Quittung)/gi)) {
|
||||
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.tenant, tenant.id),
|
||||
and(
|
||||
eq(folders.function, "incomingInvoices"),
|
||||
//@ts-ignore
|
||||
eq(folders.year, dayjs().format("YYYY"))
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
folderId = folder[0]?.id ?? null
|
||||
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "invoices")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// Mahnung
|
||||
// -------------------------------------------
|
||||
else if (message.subject?.match(/(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/gi)) {
|
||||
|
||||
const tag = await server.db
|
||||
.select({ id: filetags.id })
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenant.id),
|
||||
eq(filetags.incomingDocumentType, "reminders")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
filetypeId = tag[0]?.id ?? null
|
||||
}
|
||||
|
||||
// -------------------------------------------
|
||||
// Sonstige Dokumente → Deposit Folder
|
||||
// -------------------------------------------
|
||||
else {
|
||||
|
||||
const folder = await server.db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.tenant, tenant.id),
|
||||
eq(folders.function, "deposit")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
folderId = folder[0]?.id ?? null
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
tenant: tenant.id,
|
||||
folder: folderId,
|
||||
filetype: filetypeId
|
||||
}
|
||||
}
|
||||
175
backend/src/modules/cron/prepareIncomingInvoices.ts
Normal file
175
backend/src/modules/cron/prepareIncomingInvoices.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import dayjs from "dayjs"
|
||||
import { getInvoiceDataFromGPT } from "../../utils/gpt"
|
||||
|
||||
// Drizzle schema
|
||||
import {
|
||||
tenants,
|
||||
files,
|
||||
filetags,
|
||||
incominginvoices,
|
||||
} from "../../../db/schema"
|
||||
|
||||
import { eq, and, isNull, not } from "drizzle-orm"
|
||||
|
||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
const processInvoices = async (tenantId:number) => {
|
||||
console.log("▶ Starting Incoming Invoice Preparation")
|
||||
|
||||
const tenantsRes = await server.db
|
||||
.select()
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.orderBy(tenants.id)
|
||||
|
||||
if (!tenantsRes.length) {
|
||||
console.log("No tenants with autoPrepareIncomingInvoices = true")
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 2️⃣ Jeden Tenant einzeln verarbeiten
|
||||
// -------------------------------------------------------------
|
||||
for (const tenant of tenantsRes) {
|
||||
const tenantId = tenant.id
|
||||
|
||||
// 2.1 Datei-Tags holen für incoming invoices
|
||||
const tagRes = await server.db
|
||||
.select()
|
||||
.from(filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(filetags.tenant, tenantId),
|
||||
eq(filetags.incomingDocumentType, "invoices")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const invoiceFileTag = tagRes?.[0]?.id
|
||||
if (!invoiceFileTag) {
|
||||
server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2.2 Alle Dateien laden, die als Invoice markiert sind aber NOCH keine incominginvoice haben
|
||||
const filesRes = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(
|
||||
and(
|
||||
eq(files.tenant, tenantId),
|
||||
eq(files.type, invoiceFileTag),
|
||||
isNull(files.incominginvoice),
|
||||
eq(files.archived, false),
|
||||
not(isNull(files.path))
|
||||
)
|
||||
)
|
||||
|
||||
if (!filesRes.length) {
|
||||
console.log(`No invoice files for tenant ${tenantId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
|
||||
// -------------------------------------------------------------
|
||||
for (const file of filesRes) {
|
||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||
|
||||
const data = await getInvoiceDataFromGPT(server,file, tenantId)
|
||||
|
||||
if (!data) {
|
||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3.1 IncomingInvoice-Objekt vorbereiten
|
||||
// ---------------------------------------------------------
|
||||
let itemInfo: any = {
|
||||
tenant: tenantId,
|
||||
state: "Vorbereitet"
|
||||
}
|
||||
|
||||
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
||||
if (data.invoice_date) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||
if (data.invoice_duedate) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||
|
||||
// Payment terms mapping
|
||||
const mapPayment: any = {
|
||||
"Direct Debit": "Einzug",
|
||||
"Transfer": "Überweisung",
|
||||
"Credit Card": "Kreditkarte",
|
||||
"Other": "Sonstiges",
|
||||
}
|
||||
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
|
||||
|
||||
// 3.2 Positionszeilen konvertieren
|
||||
if (data.invoice_items?.length > 0) {
|
||||
itemInfo.accounts = data.invoice_items.map(item => ({
|
||||
account: item.account_id,
|
||||
description: item.description,
|
||||
amountNet: item.total_without_tax,
|
||||
amountTax: Number((item.total - item.total_without_tax).toFixed(2)),
|
||||
taxType: String(item.tax_rate),
|
||||
amountGross: item.total,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
}
|
||||
|
||||
// 3.3 Beschreibung generieren
|
||||
let description = ""
|
||||
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
|
||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||
if (data.invoice_items) {
|
||||
for (const item of data.invoice_items) {
|
||||
description += `${item.description} - ${item.quantity} ${item.unit} - ${item.total}\n`
|
||||
}
|
||||
}
|
||||
itemInfo.description = description.trim()
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4️⃣ IncomingInvoice erstellen
|
||||
// ---------------------------------------------------------
|
||||
const inserted = await server.db
|
||||
.insert(incominginvoices)
|
||||
.values(itemInfo)
|
||||
.returning()
|
||||
|
||||
const newInvoice = inserted?.[0]
|
||||
|
||||
if (!newInvoice) {
|
||||
server.log.error(`Failed to insert incoming invoice for file ${file.id}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 5️⃣ Datei mit incominginvoice-ID verbinden
|
||||
// ---------------------------------------------------------
|
||||
await server.db
|
||||
.update(files)
|
||||
.set({ incominginvoice: newInvoice.id })
|
||||
.where(eq(files.id, file.id))
|
||||
|
||||
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
run: async (tenant:number) => {
|
||||
await processInvoices(tenant)
|
||||
console.log("Incoming Invoice Preparation Completed.")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
38
backend/src/modules/helpdesk/helpdesk.contact.service.ts
Normal file
38
backend/src/modules/helpdesk/helpdesk.contact.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// modules/helpdesk/helpdesk.contact.service.ts
|
||||
import { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function getOrCreateContact(
|
||||
server: FastifyInstance,
|
||||
tenant_id: number,
|
||||
{ email, phone, display_name, customer_id, contact_id }: { email?: string; phone?: string; display_name?: string; customer_id?: number; contact_id?: number }
|
||||
) {
|
||||
if (!email && !phone) throw new Error('Contact must have at least an email or phone')
|
||||
|
||||
// Bestehenden Kontakt prüfen
|
||||
const { data: existing, error: findError } = await server.supabase
|
||||
.from('helpdesk_contacts')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenant_id)
|
||||
.or(`email.eq.${email || ''},phone.eq.${phone || ''}`)
|
||||
.maybeSingle()
|
||||
|
||||
if (findError) throw findError
|
||||
if (existing) return existing
|
||||
|
||||
// Anlegen
|
||||
const { data: created, error: insertError } = await server.supabase
|
||||
.from('helpdesk_contacts')
|
||||
.insert({
|
||||
tenant_id,
|
||||
email,
|
||||
phone,
|
||||
display_name,
|
||||
customer_id,
|
||||
contact_id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError) throw insertError
|
||||
return created
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// modules/helpdesk/helpdesk.conversation.service.ts
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { getOrCreateContact } from './helpdesk.contact.service.js'
|
||||
import {useNextNumberRangeNumber} from "../../utils/functions";
|
||||
|
||||
export async function createConversation(
|
||||
server: FastifyInstance,
|
||||
{
|
||||
tenant_id,
|
||||
contact,
|
||||
channel_instance_id,
|
||||
subject,
|
||||
customer_id = null,
|
||||
contact_person_id = null,
|
||||
}: {
|
||||
tenant_id: number
|
||||
contact: { email?: string; phone?: string; display_name?: string }
|
||||
channel_instance_id: string
|
||||
subject?: string,
|
||||
customer_id?: number,
|
||||
contact_person_id?: number
|
||||
}
|
||||
) {
|
||||
const contactRecord = await getOrCreateContact(server, tenant_id, contact)
|
||||
|
||||
const {usedNumber } = await useNextNumberRangeNumber(server, tenant_id, "tickets")
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.insert({
|
||||
tenant_id,
|
||||
contact_id: contactRecord.id,
|
||||
channel_instance_id,
|
||||
subject: subject || null,
|
||||
status: 'open',
|
||||
created_at: new Date().toISOString(),
|
||||
customer_id,
|
||||
contact_person_id,
|
||||
ticket_number: usedNumber
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getConversations(
|
||||
server: FastifyInstance,
|
||||
tenant_id: number,
|
||||
opts?: { status?: string; limit?: number }
|
||||
) {
|
||||
const { status, limit = 50 } = opts || {}
|
||||
|
||||
let query = server.supabase.from('helpdesk_conversations').select('*, customer_id(*)').eq('tenant_id', tenant_id)
|
||||
|
||||
if (status) query = query.eq('status', status)
|
||||
query = query.order('last_message_at', { ascending: false }).limit(limit)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
|
||||
const mappedData = data.map(entry => {
|
||||
return {
|
||||
...entry,
|
||||
customer: entry.customer_id
|
||||
}
|
||||
})
|
||||
|
||||
return mappedData
|
||||
}
|
||||
|
||||
export async function updateConversationStatus(
|
||||
server: FastifyInstance,
|
||||
conversation_id: string,
|
||||
status: string
|
||||
) {
|
||||
const valid = ['open', 'in_progress', 'waiting_for_customer', 'answered', 'closed']
|
||||
if (!valid.includes(status)) throw new Error('Invalid status')
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.update({ status })
|
||||
.eq('id', conversation_id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
60
backend/src/modules/helpdesk/helpdesk.message.service.ts
Normal file
60
backend/src/modules/helpdesk/helpdesk.message.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// modules/helpdesk/helpdesk.message.service.ts
|
||||
import { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function addMessage(
|
||||
server: FastifyInstance,
|
||||
{
|
||||
tenant_id,
|
||||
conversation_id,
|
||||
author_user_id = null,
|
||||
direction = 'incoming',
|
||||
payload,
|
||||
raw_meta = null,
|
||||
external_message_id = null,
|
||||
}: {
|
||||
tenant_id: number
|
||||
conversation_id: string
|
||||
author_user_id?: string | null
|
||||
direction?: 'incoming' | 'outgoing' | 'internal' | 'system'
|
||||
payload: any
|
||||
raw_meta?: any
|
||||
external_message_id?: string
|
||||
}
|
||||
) {
|
||||
if (!payload?.text) throw new Error('Message payload requires text content')
|
||||
|
||||
const { data: message, error } = await server.supabase
|
||||
.from('helpdesk_messages')
|
||||
.insert({
|
||||
tenant_id,
|
||||
conversation_id,
|
||||
author_user_id,
|
||||
direction,
|
||||
payload,
|
||||
raw_meta,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Letzte Nachricht aktualisieren
|
||||
await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.update({ last_message_at: new Date().toISOString() })
|
||||
.eq('id', conversation_id)
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
export async function getMessages(server: FastifyInstance, conversation_id: string) {
|
||||
const { data, error } = await server.supabase
|
||||
.from('helpdesk_messages')
|
||||
.select('*')
|
||||
.eq('conversation_id', conversation_id)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
148
backend/src/modules/notification.service.ts
Normal file
148
backend/src/modules/notification.service.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// services/notification.service.ts
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {secrets} from "../utils/secrets";
|
||||
|
||||
export type NotificationStatus = 'queued' | 'sent' | 'failed';
|
||||
|
||||
export interface TriggerInput {
|
||||
tenantId: number;
|
||||
userId: string; // muss auf public.auth_users.id zeigen
|
||||
eventType: string; // muss in notifications_event_types existieren
|
||||
title: string; // Betreff/Title
|
||||
message: string; // Klartext-Inhalt
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UserDirectoryInfo {
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export type UserDirectory = (server: FastifyInstance, userId: string, tenantId: number) => Promise<UserDirectoryInfo | null>;
|
||||
|
||||
export class NotificationService {
|
||||
constructor(
|
||||
private server: FastifyInstance,
|
||||
private getUser: UserDirectory
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Löst eine E-Mail-Benachrichtigung aus:
|
||||
* - Validiert den Event-Typ
|
||||
* - Legt einen Datensatz in notifications_items an (status: queued)
|
||||
* - Versendet E-Mail (FEDEO Branding)
|
||||
* - Aktualisiert status/sent_at bzw. error
|
||||
*/
|
||||
async trigger(input: TriggerInput) {
|
||||
const { tenantId, userId, eventType, title, message, payload } = input;
|
||||
const supabase = this.server.supabase;
|
||||
|
||||
// 1) Event-Typ prüfen (aktiv?)
|
||||
const { data: eventTypeRow, error: etErr } = await supabase
|
||||
.from('notifications_event_types')
|
||||
.select('event_key,is_active')
|
||||
.eq('event_key', eventType)
|
||||
.maybeSingle();
|
||||
|
||||
if (etErr || !eventTypeRow || eventTypeRow.is_active !== true) {
|
||||
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`);
|
||||
}
|
||||
|
||||
// 2) Zieladresse beschaffen
|
||||
const user = await this.getUser(this.server, userId, tenantId);
|
||||
if (!user?.email) {
|
||||
throw new Error(`Nutzer ${userId} hat keine E-Mail-Adresse`);
|
||||
}
|
||||
|
||||
// 3) Notification anlegen (status: queued)
|
||||
const { data: inserted, error: insErr } = await supabase
|
||||
.from('notifications_items')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
event_type: eventType,
|
||||
title,
|
||||
message,
|
||||
payload: payload ?? null,
|
||||
channel: 'email',
|
||||
status: 'queued'
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (insErr || !inserted) {
|
||||
throw new Error(`Fehler beim Einfügen der Notification: ${insErr?.message}`);
|
||||
}
|
||||
|
||||
// 4) E-Mail versenden
|
||||
try {
|
||||
await this.sendEmail(user.email, title, message);
|
||||
|
||||
await supabase
|
||||
.from('notifications_items')
|
||||
.update({ status: 'sent', sent_at: new Date().toISOString() })
|
||||
.eq('id', inserted.id);
|
||||
|
||||
return { success: true, id: inserted.id };
|
||||
} catch (err: any) {
|
||||
await supabase
|
||||
.from('notifications_items')
|
||||
.update({ status: 'failed', error: String(err?.message || err) })
|
||||
.eq('id', inserted.id);
|
||||
|
||||
this.server.log.error({ err, notificationId: inserted.id }, 'E-Mail Versand fehlgeschlagen');
|
||||
return { success: false, error: err?.message || 'E-Mail Versand fehlgeschlagen' };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- private helpers ------------------------------------------------------
|
||||
|
||||
private async sendEmail(to: string, subject: string, message: string) {
|
||||
const nodemailer = await import('nodemailer');
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: secrets.MAILER_SMTP_HOST,
|
||||
port: Number(secrets.MAILER_SMTP_PORT),
|
||||
secure: secrets.MAILER_SMTP_SSL === 'true',
|
||||
auth: {
|
||||
user: secrets.MAILER_SMTP_USER,
|
||||
pass: secrets.MAILER_SMTP_PASS
|
||||
}
|
||||
});
|
||||
|
||||
const html = this.renderFedeoHtml(subject, message);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: secrets.MAILER_FROM,
|
||||
to,
|
||||
subject,
|
||||
text: message,
|
||||
html
|
||||
});
|
||||
}
|
||||
|
||||
private renderFedeoHtml(title: string, message: string) {
|
||||
return `
|
||||
<html><body style="font-family:sans-serif;color:#222">
|
||||
<div style="border:1px solid #ddd;border-radius:8px;padding:16px;max-width:600px;margin:auto">
|
||||
<h2 style="color:#0f62fe;margin:0 0 12px">FEDEO</h2>
|
||||
<h3 style="margin:0 0 8px">${this.escapeHtml(title)}</h3>
|
||||
<p>${this.nl2br(this.escapeHtml(message))}</p>
|
||||
<hr style="margin:16px 0;border:none;border-top:1px solid #eee"/>
|
||||
<p style="font-size:12px;color:#666">Automatisch generiert von FEDEO</p>
|
||||
</div>
|
||||
</body></html>
|
||||
`;
|
||||
}
|
||||
|
||||
// simple escaping (ausreichend für unser Template)
|
||||
private escapeHtml(s: string) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
private nl2br(s: string) {
|
||||
return s.replace(/\n/g, '<br/>');
|
||||
}
|
||||
}
|
||||
406
backend/src/modules/publiclinks.service.ts
Normal file
406
backend/src/modules/publiclinks.service.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import {and, eq, inArray, not} from 'drizzle-orm';
|
||||
import * as schema from '../../db/schema';
|
||||
import {useNextNumberRangeNumber} from "../utils/functions"; // Pfad anpassen
|
||||
|
||||
|
||||
export const publicLinkService = {
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Public Link
|
||||
*/
|
||||
async createLink(server: FastifyInstance, tenantId: number,
|
||||
name: string,
|
||||
isProtected?: boolean,
|
||||
pin?: string,
|
||||
customToken?: string,
|
||||
config?: Record<string, any>,
|
||||
defaultProfileId?: string) {
|
||||
let pinHash: string | null = null;
|
||||
|
||||
// 1. PIN Hashen, falls Schutz aktiviert ist
|
||||
if (isProtected && pin) {
|
||||
pinHash = await bcrypt.hash(pin, 10);
|
||||
} else if (isProtected && !pin) {
|
||||
throw new Error("Für geschützte Links muss eine PIN angegeben werden.");
|
||||
}
|
||||
|
||||
// 2. Token generieren oder Custom Token verwenden
|
||||
let token = customToken;
|
||||
|
||||
if (!token) {
|
||||
// Generiere einen zufälligen Token (z.B. hex string)
|
||||
// Alternativ: nanoid nutzen, falls installiert
|
||||
token = crypto.randomBytes(12).toString('hex');
|
||||
}
|
||||
|
||||
// Prüfen, ob Token schon existiert (nur bei Custom Token wichtig)
|
||||
if (customToken) {
|
||||
const existing = await server.db.query.publicLinks.findFirst({
|
||||
where: eq(schema.publicLinks.token, token)
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error(`Der Token '${token}' ist bereits vergeben.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. DB Insert
|
||||
const [newLink] = await server.db.insert(schema.publicLinks).values({
|
||||
tenant: tenantId,
|
||||
name: name,
|
||||
token: token,
|
||||
isProtected: isProtected || false,
|
||||
pinHash: pinHash,
|
||||
config: config || {},
|
||||
defaultProfile: defaultProfileId || null,
|
||||
active: true
|
||||
}).returning();
|
||||
|
||||
return newLink;
|
||||
},
|
||||
|
||||
/**
|
||||
* Listet alle Links für einen Tenant auf (für die Verwaltungs-UI später)
|
||||
*/
|
||||
async getLinksByTenant(server: FastifyInstance, tenantId: number) {
|
||||
return server.db.select()
|
||||
.from(schema.publicLinks)
|
||||
.where(eq(schema.publicLinks.tenant, tenantId));
|
||||
},
|
||||
|
||||
|
||||
async getLinkContext(server: FastifyInstance, token: string, providedPin?: string) {
|
||||
// 1. Link laden & Checks
|
||||
const linkConfig = await server.db.query.publicLinks.findFirst({
|
||||
where: eq(schema.publicLinks.token, token)
|
||||
});
|
||||
|
||||
if (!linkConfig || !linkConfig.active) throw new Error("Link_NotFound");
|
||||
|
||||
// 2. PIN Check
|
||||
if (linkConfig.isProtected) {
|
||||
if (!providedPin) throw new Error("Pin_Required");
|
||||
const isValid = await bcrypt.compare(providedPin, linkConfig.pinHash || "");
|
||||
if (!isValid) throw new Error("Pin_Invalid");
|
||||
}
|
||||
|
||||
const tenantId = linkConfig.tenant;
|
||||
const config = linkConfig.config as any;
|
||||
|
||||
// --- RESSOURCEN & FILTER ---
|
||||
|
||||
// Standardmäßig alles laden, wenn 'resources' nicht definiert ist
|
||||
const requestedResources: string[] = config.resources || ['profiles', 'projects', 'services', 'units'];
|
||||
const filters = config.filters || {}; // Erwartet jetzt: { projects: [1,2], services: [3,4] }
|
||||
|
||||
const queryPromises: Record<string, Promise<any[]>> = {};
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 1. PROFILES (Mitarbeiter)
|
||||
// ---------------------------------------------------------
|
||||
if (requestedResources.includes('profiles')) {
|
||||
let profileCondition = and(
|
||||
eq(schema.authProfiles.tenant_id, tenantId),
|
||||
eq(schema.authProfiles.active, true)
|
||||
);
|
||||
|
||||
// Sicherheits-Feature: Default Profil erzwingen
|
||||
if (linkConfig.defaultProfile) {
|
||||
profileCondition = and(profileCondition, eq(schema.authProfiles.id, linkConfig.defaultProfile));
|
||||
}
|
||||
|
||||
// Optional: Auch hier Filter ermöglichen (falls man z.B. nur 3 bestimmte MA zur Auswahl geben will)
|
||||
if (filters.profiles && Array.isArray(filters.profiles) && filters.profiles.length > 0) {
|
||||
profileCondition = and(profileCondition, inArray(schema.authProfiles.id, filters.profiles));
|
||||
}
|
||||
|
||||
queryPromises.profiles = server.db.select({
|
||||
id: schema.authProfiles.id,
|
||||
fullName: schema.authProfiles.full_name
|
||||
})
|
||||
.from(schema.authProfiles)
|
||||
.where(profileCondition);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2. PROJECTS (Aufträge)
|
||||
// ---------------------------------------------------------
|
||||
if (requestedResources.includes('projects')) {
|
||||
let projectCondition = and(
|
||||
eq(schema.projects.tenant, tenantId),
|
||||
not(eq(schema.projects.active_phase, 'Abgeschlossen'))
|
||||
);
|
||||
|
||||
// NEU: Zugriff direkt auf filters.projects
|
||||
if (filters.projects && Array.isArray(filters.projects) && filters.projects.length > 0) {
|
||||
projectCondition = and(projectCondition, inArray(schema.projects.id, filters.projects));
|
||||
}
|
||||
|
||||
queryPromises.projects = server.db.select({
|
||||
id: schema.projects.id,
|
||||
name: schema.projects.name
|
||||
})
|
||||
.from(schema.projects)
|
||||
.where(projectCondition);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3. SERVICES (Tätigkeiten)
|
||||
// ---------------------------------------------------------
|
||||
if (requestedResources.includes('services')) {
|
||||
let serviceCondition = eq(schema.services.tenant, tenantId);
|
||||
|
||||
// NEU: Zugriff direkt auf filters.services
|
||||
if (filters.services && Array.isArray(filters.services) && filters.services.length > 0) {
|
||||
serviceCondition = and(serviceCondition, inArray(schema.services.id, filters.services));
|
||||
}
|
||||
|
||||
queryPromises.services = server.db.select({
|
||||
id: schema.services.id,
|
||||
name: schema.services.name,
|
||||
unit: schema.services.unit
|
||||
})
|
||||
.from(schema.services)
|
||||
.where(serviceCondition);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4. UNITS (Einheiten)
|
||||
// ---------------------------------------------------------
|
||||
if (requestedResources.includes('units')) {
|
||||
// Units werden meist global geladen, könnten aber auch gefiltert werden
|
||||
queryPromises.units = server.db.select().from(schema.units);
|
||||
}
|
||||
|
||||
// --- QUERY AUSFÜHRUNG ---
|
||||
const results = await Promise.all(Object.values(queryPromises));
|
||||
const keys = Object.keys(queryPromises);
|
||||
|
||||
const dataResponse: Record<string, any[]> = {
|
||||
profiles: [],
|
||||
projects: [],
|
||||
services: [],
|
||||
units: []
|
||||
};
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
dataResponse[key] = results[index];
|
||||
});
|
||||
|
||||
return {
|
||||
config: linkConfig.config,
|
||||
meta: {
|
||||
formName: linkConfig.name,
|
||||
defaultProfileId: linkConfig.defaultProfile
|
||||
},
|
||||
data: dataResponse
|
||||
};
|
||||
},
|
||||
|
||||
async submitFormData(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
|
||||
// 1. Validierung (Token & PIN)
|
||||
const linkConfig = await server.db.query.publicLinks.findFirst({
|
||||
where: eq(schema.publicLinks.token, token)
|
||||
});
|
||||
|
||||
if (!linkConfig || !linkConfig.active) throw new Error("Link_NotFound");
|
||||
|
||||
if (linkConfig.isProtected) {
|
||||
if (!providedPin) throw new Error("Pin_Required");
|
||||
const isValid = await bcrypt.compare(providedPin, linkConfig.pinHash || "");
|
||||
if (!isValid) throw new Error("Pin_Invalid");
|
||||
}
|
||||
|
||||
const tenantId = linkConfig.tenant;
|
||||
const config = linkConfig.config as any;
|
||||
|
||||
// 2. USER ID AUFLÖSEN
|
||||
// Wir holen die profileId aus dem Link (Default) oder dem Payload (User-Auswahl)
|
||||
const rawProfileId = linkConfig.defaultProfile || payload.profile;
|
||||
if (!rawProfileId) throw new Error("Profile_Missing");
|
||||
|
||||
// Profil laden, um die user_id zu bekommen
|
||||
const authProfile = await server.db.query.authProfiles.findFirst({
|
||||
where: eq(schema.authProfiles.id, rawProfileId)
|
||||
});
|
||||
|
||||
if (!authProfile) throw new Error("Profile_Not_Found");
|
||||
|
||||
// Da du sagtest, es gibt immer einen User, verlassen wir uns darauf.
|
||||
// Falls null, werfen wir einen Fehler, da die DB sonst beim Insert knallt.
|
||||
const userId = authProfile.user_id;
|
||||
if (!userId) throw new Error("Profile_Has_No_User_Assigned");
|
||||
|
||||
|
||||
// Helper für Datum
|
||||
const normalizeDate = (val: any) => {
|
||||
if (!val) return null
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// SCHRITT A: Stammdaten laden
|
||||
// =================================================================
|
||||
const project = await server.db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, payload.project)
|
||||
});
|
||||
if (!project) throw new Error("Project not found");
|
||||
|
||||
const customer = await server.db.query.customers.findFirst({
|
||||
where: eq(schema.customers.id, project.customer)
|
||||
});
|
||||
|
||||
const service = await server.db.query.services.findFirst({
|
||||
where: eq(schema.services.id, payload.service)
|
||||
});
|
||||
if (!service) throw new Error("Service not found");
|
||||
|
||||
// Texttemplates & Letterhead laden
|
||||
const texttemplates = await server.db.query.texttemplates.findMany({
|
||||
where: (t, {and, eq}) => and(
|
||||
eq(t.tenant, tenantId),
|
||||
eq(t.documentType, 'deliveryNotes')
|
||||
)
|
||||
});
|
||||
const letterhead = await server.db.query.letterheads.findFirst({
|
||||
where: eq(schema.letterheads.tenant, tenantId)
|
||||
});
|
||||
|
||||
// =================================================================
|
||||
// SCHRITT B: Nummernkreis generieren
|
||||
// =================================================================
|
||||
const {usedNumber} = await useNextNumberRangeNumber(server, tenantId, "deliveryNotes");
|
||||
|
||||
// =================================================================
|
||||
// SCHRITT C: Berechnungen
|
||||
// =================================================================
|
||||
const startDate = normalizeDate(payload.startDate) || new Date();
|
||||
let endDate = normalizeDate(payload.endDate);
|
||||
|
||||
// Fallback Endzeit (+1h)
|
||||
if (!endDate) {
|
||||
endDate = server.dayjs(startDate).add(1, 'hour').toDate();
|
||||
}
|
||||
|
||||
// Menge berechnen
|
||||
let quantity = payload.quantity;
|
||||
if (!quantity && payload.totalHours) quantity = payload.totalHours;
|
||||
if (!quantity) {
|
||||
const diffMin = server.dayjs(endDate).diff(server.dayjs(startDate), 'minute');
|
||||
quantity = Number((diffMin / 60).toFixed(2));
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// SCHRITT D: Lieferschein erstellen
|
||||
// =================================================================
|
||||
const createDocData = {
|
||||
tenant: tenantId,
|
||||
type: "deliveryNotes",
|
||||
state: "Entwurf",
|
||||
customer: project.customer,
|
||||
//@ts-ignore
|
||||
address: customer ? {zip: customer.infoData.zip, city: customer.infoData.city, street: customer.infoData.street,} : {},
|
||||
project: project.id,
|
||||
documentNumber: usedNumber,
|
||||
documentDate: String(new Date()), // Schema sagt 'text', evtl toISOString() besser?
|
||||
deliveryDate: String(startDate), // Schema sagt 'text'
|
||||
deliveryDateType: "Leistungsdatum",
|
||||
createdBy: userId, // WICHTIG: Hier die User ID
|
||||
created_by: userId, // WICHTIG: Hier die User ID
|
||||
title: "Lieferschein",
|
||||
description: "",
|
||||
startText: texttemplates.find((i: any) => i.default && i.pos === "startText")?.text || "",
|
||||
endText: texttemplates.find((i: any) => i.default && i.pos === "endText")?.text || "",
|
||||
rows: [
|
||||
{
|
||||
pos: "1",
|
||||
mode: "service",
|
||||
service: service.id,
|
||||
text: service.name,
|
||||
unit: service.unit,
|
||||
quantity: quantity,
|
||||
description: service.description || null,
|
||||
descriptionText: service.description || null,
|
||||
agriculture: {
|
||||
dieselUsage: payload.dieselUsage || null,
|
||||
}
|
||||
}
|
||||
],
|
||||
contactPerson: userId, // WICHTIG: Hier die User ID
|
||||
letterhead: letterhead?.id,
|
||||
};
|
||||
|
||||
const [createdDoc] = await server.db.insert(schema.createddocuments)
|
||||
//@ts-ignore
|
||||
.values(createDocData)
|
||||
.returning();
|
||||
|
||||
// =================================================================
|
||||
// SCHRITT E: Zeiterfassung Events
|
||||
// =================================================================
|
||||
if (config.features?.timeTracking) {
|
||||
|
||||
// Metadaten für das Event
|
||||
const eventMetadata = {
|
||||
project: project.id,
|
||||
service: service.id,
|
||||
description: payload.description || "",
|
||||
generatedDocumentId: createdDoc.id
|
||||
};
|
||||
|
||||
// 1. START EVENT
|
||||
await server.db.insert(schema.stafftimeevents).values({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId, // WICHTIG: User ID
|
||||
actortype: "user",
|
||||
actoruser_id: userId, // WICHTIG: User ID
|
||||
eventtime: startDate,
|
||||
eventtype: "START",
|
||||
source: "PUBLIC_LINK",
|
||||
metadata: eventMetadata // WICHTIG: Schema heißt 'metadata', nicht 'payload'
|
||||
});
|
||||
|
||||
// 2. STOP EVENT
|
||||
await server.db.insert(schema.stafftimeevents).values({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
actortype: "user",
|
||||
actoruser_id: userId,
|
||||
eventtime: endDate,
|
||||
eventtype: "STOP",
|
||||
source: "PUBLIC_LINK",
|
||||
metadata: eventMetadata
|
||||
});
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// SCHRITT F: History Items
|
||||
// =================================================================
|
||||
const historyItemsToCreate = [];
|
||||
|
||||
if (payload.description) {
|
||||
historyItemsToCreate.push({
|
||||
tenant: tenantId,
|
||||
createdBy: userId, // WICHTIG: User ID
|
||||
text: `Notiz aus Webformular Lieferschein ${createdDoc.documentNumber}: ${payload.description}`,
|
||||
project: project.id,
|
||||
createdDocument: createdDoc.id
|
||||
});
|
||||
}
|
||||
|
||||
historyItemsToCreate.push({
|
||||
tenant: tenantId,
|
||||
createdBy: userId, // WICHTIG: User ID
|
||||
text: `Webformular abgeschickt. Lieferschein ${createdDoc.documentNumber} erstellt. Zeit gebucht (Start/Stop).`,
|
||||
project: project.id,
|
||||
createdDocument: createdDoc.id
|
||||
});
|
||||
|
||||
await server.db.insert(schema.historyitems).values(historyItemsToCreate);
|
||||
|
||||
return {success: true, documentNumber: createdDoc.documentNumber};
|
||||
}
|
||||
}
|
||||
725
backend/src/modules/serialexecution.service.ts
Normal file
725
backend/src/modules/serialexecution.service.ts
Normal file
@@ -0,0 +1,725 @@
|
||||
import dayjs from "dayjs";
|
||||
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
||||
import Handlebars from "handlebars";
|
||||
import axios from "axios";
|
||||
import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren
|
||||
|
||||
// DEINE IMPORTS
|
||||
import * as schema from "../../db/schema"; // Importiere dein Drizzle Schema
|
||||
import { saveFile } from "../utils/files";
|
||||
import {FastifyInstance} from "fastify";
|
||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
||||
|
||||
dayjs.extend(quarterOfYear);
|
||||
|
||||
|
||||
export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => {
|
||||
try {
|
||||
console.log(executedBy)
|
||||
|
||||
const executionDayjs = dayjs(executionDate);
|
||||
|
||||
console.log(`Starte manuelle Generierung für Tenant ${tenantId} am ${executionDate}`);
|
||||
|
||||
// 1. Tenant laden (Drizzle)
|
||||
// Wir nehmen an, dass 'tenants' im Schema definiert ist
|
||||
const [tenant] = await server.db
|
||||
.select()
|
||||
.from(schema.tenants)
|
||||
.where(eq(schema.tenants.id, tenantId))
|
||||
.limit(1);
|
||||
|
||||
if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`);
|
||||
|
||||
// 2. Templates laden
|
||||
const templates = await server.db
|
||||
.select()
|
||||
.from(schema.createddocuments)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.createddocuments.tenant, tenantId),
|
||||
eq(schema.createddocuments.type, "serialInvoices"),
|
||||
inArray(schema.createddocuments.id, templateIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (templates.length === 0) {
|
||||
console.warn("Keine passenden Vorlagen gefunden.");
|
||||
return [];
|
||||
}
|
||||
|
||||
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
|
||||
const folderId = await getFolderId(server,tenantId);
|
||||
const fileTypeId = await getFileTypeId(server,tenantId);
|
||||
|
||||
const results = [];
|
||||
|
||||
const [executionRecord] = await server.db
|
||||
.insert(schema.serialExecutions)
|
||||
.values({
|
||||
tenant: tenantId,
|
||||
executionDate: executionDayjs.toDate(),
|
||||
status: "draft",
|
||||
createdBy: executedBy,
|
||||
summary: `${templateIds.length} Vorlagen verarbeitet`
|
||||
})
|
||||
.returning();
|
||||
|
||||
console.log(executionRecord);
|
||||
|
||||
// 4. Loop durch die Templates
|
||||
for (const template of templates) {
|
||||
try {
|
||||
const resultId = await processSingleTemplate(
|
||||
server,
|
||||
template,
|
||||
tenant,
|
||||
executionDayjs,
|
||||
folderId,
|
||||
fileTypeId,
|
||||
executedBy,
|
||||
executionRecord.id
|
||||
);
|
||||
results.push({ id: template.id, status: "success", newDocumentId: resultId });
|
||||
} catch (e: any) {
|
||||
console.error(`Fehler bei Template ${template.id}:`, e);
|
||||
results.push({ id: template.id, status: "error", error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => {
|
||||
try {
|
||||
console.log(`Beende Ausführung ${executionId}...`);
|
||||
|
||||
// 1. Execution und Tenant laden
|
||||
|
||||
const [executionRecord] = await server.db
|
||||
.select()
|
||||
.from(schema.serialExecutions)// @ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId))
|
||||
.limit(1);
|
||||
|
||||
if (!executionRecord) throw new Error("Execution nicht gefunden");
|
||||
|
||||
console.log(executionRecord);
|
||||
|
||||
const tenantId = executionRecord.tenant;
|
||||
|
||||
console.log(tenantId)
|
||||
|
||||
// Tenant laden (für Settings etc.)
|
||||
const [tenant] = await server.db
|
||||
.select()
|
||||
.from(schema.tenants)
|
||||
.where(eq(schema.tenants.id, tenantId))
|
||||
.limit(1);
|
||||
|
||||
// 2. Status auf "processing" setzen (optional, damit UI feedback hat)
|
||||
|
||||
/*await server.db
|
||||
.update(schema.serialExecutions)
|
||||
.set({ status: "processing" })// @ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId));*/
|
||||
|
||||
// 3. Alle erstellten Dokumente dieser Execution laden
|
||||
const documents = await server.db
|
||||
.select()
|
||||
.from(schema.createddocuments)
|
||||
.where(eq(schema.createddocuments.serialexecution, executionId));
|
||||
|
||||
console.log(`${documents.length} Dokumente werden finalisiert...`);
|
||||
|
||||
// 4. IDs für File-System laden (nur einmalig nötig)
|
||||
const folderId = await getFolderId(server, tenantId);
|
||||
const fileTypeId = await getFileTypeId(server, tenantId);
|
||||
|
||||
// Globale Daten laden, die für alle gleich sind (Optimierung)
|
||||
const [units, products, services] = await Promise.all([
|
||||
server.db.select().from(schema.units),
|
||||
server.db.select().from(schema.products).where(eq(schema.products.tenant, tenantId)),
|
||||
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)),
|
||||
]);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// 5. Loop durch Dokumente
|
||||
for (const doc of documents) {
|
||||
try {
|
||||
|
||||
const [letterhead] = await Promise.all([
|
||||
/*fetchById(server, schema.contacts, doc.contact),
|
||||
fetchById(server, schema.customers, doc.customer),
|
||||
fetchById(server, schema.authProfiles, doc.contactPerson), // oder createdBy, je nach Logik
|
||||
fetchById(server, schema.projects, doc.project),
|
||||
fetchById(server, schema.contracts, doc.contract),*/
|
||||
doc.letterhead ? fetchById(server, schema.letterheads, doc.letterhead) : null
|
||||
]);
|
||||
|
||||
const pdfData = await getCloseData(
|
||||
server,
|
||||
doc,
|
||||
tenant,
|
||||
units,
|
||||
products,
|
||||
services,
|
||||
);
|
||||
|
||||
console.log(pdfData);
|
||||
|
||||
// D. PDF Generieren
|
||||
const pdfBase64 = await createInvoicePDF(server,"base64",pdfData, letterhead?.path);
|
||||
|
||||
console.log(pdfBase64);
|
||||
|
||||
// E. Datei speichern
|
||||
// @ts-ignore
|
||||
const fileBuffer = Buffer.from(pdfBase64.base64, "base64");
|
||||
const filename = `${pdfData.documentNumber}.pdf`;
|
||||
|
||||
await saveFile(
|
||||
server,
|
||||
tenantId,
|
||||
null, // User ID (hier ggf. executionRecord.createdBy nutzen wenn verfügbar)
|
||||
fileBuffer,
|
||||
folderId,
|
||||
fileTypeId,
|
||||
{
|
||||
createddocument: doc.id,
|
||||
filename: filename,
|
||||
filesize: fileBuffer.length // Falls saveFile das braucht
|
||||
}
|
||||
);
|
||||
|
||||
// F. Dokument in DB final updaten
|
||||
await server.db
|
||||
.update(schema.createddocuments)
|
||||
.set({
|
||||
state: "Gebucht",
|
||||
documentNumber: pdfData.documentNumber,
|
||||
title: pdfData.title,
|
||||
pdf_path: filename // Optional, falls du den Pfad direkt am Doc speicherst
|
||||
})
|
||||
.where(eq(schema.createddocuments.id, doc.id));
|
||||
|
||||
successCount++;
|
||||
|
||||
} catch (innerErr) {
|
||||
console.error(`Fehler beim Finalisieren von Doc ID ${doc.id}:`, innerErr);
|
||||
errorCount++;
|
||||
// Optional: Status des einzelnen Dokuments auf Error setzen
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Execution abschließen
|
||||
const finalStatus = errorCount > 0 ? "error" : "completed"; // Oder 'completed' auch wenn Teilerfolge
|
||||
|
||||
|
||||
await server.db
|
||||
.update(schema.serialExecutions)
|
||||
.set({
|
||||
status: finalStatus,
|
||||
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
|
||||
})// @ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId));
|
||||
|
||||
return { success: true, processed: successCount, errors: errorCount };
|
||||
|
||||
} catch (error) {
|
||||
console.error("Critical Error in finishManualGeneration:", error);
|
||||
// Execution auf Error setzen
|
||||
// @ts-ignore
|
||||
await server.db
|
||||
.update(schema.serialExecutions)
|
||||
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
|
||||
//@ts-ignore
|
||||
.where(eq(schema.serialExecutions.id, executionId));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Verarbeitet eine einzelne Vorlage
|
||||
*/
|
||||
async function processSingleTemplate(server: FastifyInstance, template: any, tenant: any,executionDate: dayjs.Dayjs,folderId: string,fileTypeId: string,executedBy: string,executionId: string) {
|
||||
// A. Zugehörige Daten parallel laden
|
||||
const [contact, customer, profile, project, contract, units, products, services, letterhead] = await Promise.all([
|
||||
fetchById(server, schema.contacts, template.contact),
|
||||
fetchById(server, schema.customers, template.customer),
|
||||
fetchById(server, schema.authProfiles, template.contactPerson),
|
||||
fetchById(server, schema.projects, template.project),
|
||||
fetchById(server, schema.contracts, template.contract),
|
||||
server.db.select().from(schema.units),
|
||||
server.db.select().from(schema.products).where(eq(schema.products.tenant, tenant.id)),
|
||||
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenant.id)),
|
||||
template.letterhead ? fetchById(server, schema.letterheads, template.letterhead) : null
|
||||
]);
|
||||
|
||||
// B. Datumsberechnung (Logik aus dem Original)
|
||||
const { firstDate, lastDate } = calculateDateRange(template.serialConfig, executionDate);
|
||||
|
||||
// C. Rechnungsnummer & Save Data
|
||||
const savePayload = await getSaveData(
|
||||
template,
|
||||
tenant,
|
||||
firstDate,
|
||||
lastDate,
|
||||
executionDate.toISOString(),
|
||||
executedBy
|
||||
);
|
||||
|
||||
const payloadWithRelation = {
|
||||
...savePayload,
|
||||
serialexecution: executionId
|
||||
};
|
||||
|
||||
// D. Dokument in DB anlegen (Drizzle Insert)
|
||||
const [createdDoc] = await server.db
|
||||
.insert(schema.createddocuments)
|
||||
.values(payloadWithRelation)
|
||||
.returning(); // Wichtig für Postgres: returning() gibt das erstellte Objekt zurück
|
||||
|
||||
return createdDoc.id;
|
||||
}
|
||||
|
||||
// --- Drizzle Helper ---
|
||||
|
||||
async function fetchById(server: FastifyInstance, table: any, id: number | null) {
|
||||
if (!id) return null;
|
||||
const [result] = await server.db.select().from(table).where(eq(table.id, id)).limit(1);
|
||||
return result || null;
|
||||
}
|
||||
|
||||
async function getFolderId(server:FastifyInstance, tenantId: number) {
|
||||
const [folder] = await server.db
|
||||
.select({ id: schema.folders.id })
|
||||
.from(schema.folders)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.folders.tenant, tenantId),
|
||||
eq(schema.folders.function, "invoices"), // oder 'invoices', check deine DB
|
||||
eq(schema.folders.year, dayjs().format("YYYY"))
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return folder?.id;
|
||||
}
|
||||
|
||||
async function getFileTypeId(server: FastifyInstance,tenantId: number) {
|
||||
const [tag] = await server.db
|
||||
.select({ id: schema.filetags.id })
|
||||
.from(schema.filetags)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.filetags.tenant, tenantId),
|
||||
eq(schema.filetags.createdDocumentType, "invoices")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return tag?.id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Logik Helper (Unverändert zur Business Logik) ---
|
||||
|
||||
function calculateDateRange(config: any, executionDate: dayjs.Dayjs) {
|
||||
// Basis nehmen
|
||||
let baseDate = executionDate;
|
||||
|
||||
let firstDate = baseDate;
|
||||
let lastDate = baseDate;
|
||||
|
||||
if (config.intervall === "monatlich" && config.dateDirection === "Rückwirkend") {
|
||||
// 1. Monat abziehen
|
||||
// 2. Start/Ende des Monats berechnen
|
||||
// 3. WICHTIG: Zeit hart auf 12:00:00 setzen, damit Zeitzonen das Datum nicht kippen
|
||||
firstDate = baseDate.subtract(1, "month").startOf("month").hour(12).minute(0).second(0).millisecond(0);
|
||||
lastDate = baseDate.subtract(1, "month").endOf("month").hour(12).minute(0).second(0).millisecond(0);
|
||||
|
||||
} else if (config.intervall === "vierteljährlich" && config.dateDirection === "Rückwirkend") {
|
||||
|
||||
firstDate = baseDate.subtract(1, "quarter").startOf("quarter").hour(12).minute(0).second(0).millisecond(0);
|
||||
lastDate = baseDate.subtract(1, "quarter").endOf("quarter").hour(12).minute(0).second(0).millisecond(0);
|
||||
}
|
||||
|
||||
// Das Ergebnis ist nun z.B.:
|
||||
// firstDate: '2025-12-01T12:00:00.000Z' (Eindeutig der 1. Dezember)
|
||||
// lastDate: '2025-12-31T12:00:00.000Z' (Eindeutig der 31. Dezember)
|
||||
|
||||
return {
|
||||
firstDate: firstDate.toISOString(),
|
||||
lastDate: lastDate.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async function getSaveData(item: any, tenant: any, firstDate: string, lastDate: string, executionDate: string, executedBy: string) {
|
||||
const cleanRows = item.rows.map((row: any) => ({
|
||||
...row,
|
||||
descriptionText: row.description || null,
|
||||
}));
|
||||
|
||||
//const documentNumber = await this.useNextInvoicesNumber(item.tenant);
|
||||
|
||||
return {
|
||||
tenant: item.tenant,
|
||||
type: "invoices",
|
||||
state: "Entwurf",
|
||||
customer: item.customer,
|
||||
contact: item.contact,
|
||||
contract: item.contract,
|
||||
address: item.address,
|
||||
project: item.project,
|
||||
documentDate: executionDate,
|
||||
deliveryDate: firstDate,
|
||||
deliveryDateEnd: lastDate,
|
||||
paymentDays: item.paymentDays,
|
||||
payment_type: item.payment_type,
|
||||
deliveryDateType: item.deliveryDateType,
|
||||
info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt
|
||||
createdBy: item.createdBy,
|
||||
created_by: item.created_by,
|
||||
title: `Rechnung-Nr. XXX`,
|
||||
description: item.description,
|
||||
startText: item.startText,
|
||||
endText: item.endText,
|
||||
rows: cleanRows, // JSON Array
|
||||
contactPerson: item.contactPerson,
|
||||
linkedDocument: item.linkedDocument,
|
||||
letterhead: item.letterhead,
|
||||
taxType: item.taxType,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCloseData(server:FastifyInstance,item: any, tenant: any, units, products,services) {
|
||||
const documentNumber = await useNextNumberRangeNumber(server,tenant.id,"invoices");
|
||||
|
||||
console.log(item);
|
||||
|
||||
const [contact, customer, project, contract] = await Promise.all([
|
||||
fetchById(server, schema.contacts, item.contact),
|
||||
fetchById(server, schema.customers, item.customer),
|
||||
fetchById(server, schema.projects, item.project),
|
||||
fetchById(server, schema.contracts, item.contract),
|
||||
item.letterhead ? fetchById(server, schema.letterheads, item.letterhead) : null
|
||||
|
||||
]);
|
||||
|
||||
const profile = (await server.db.select().from(schema.authProfiles).where(and(eq(schema.authProfiles.user_id, item.created_by),eq(schema.authProfiles.tenant_id,tenant.id))).limit(1))[0];
|
||||
|
||||
console.log(profile)
|
||||
|
||||
const pdfData = getDocumentDataBackend(
|
||||
{
|
||||
...item,
|
||||
state: "Gebucht",
|
||||
documentNumber: documentNumber.usedNumber,
|
||||
title: `Rechnung-Nr. ${documentNumber.usedNumber}`,
|
||||
}, // Das Dokument (mit neuer Nummer)
|
||||
tenant, // Tenant Object
|
||||
customer, // Customer Object
|
||||
contact, // Contact Object (kann null sein)
|
||||
profile, // User Profile (Contact Person)
|
||||
project, // Project Object
|
||||
contract, // Contract Object
|
||||
units, // Units Array
|
||||
products, // Products Array
|
||||
services // Services Array
|
||||
);
|
||||
|
||||
|
||||
return pdfData;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Formatiert Zahlen zu deutscher Währung
|
||||
function renderCurrency(value: any, currency = "€") {
|
||||
if (value === undefined || value === null) return "0,00 " + currency;
|
||||
return Number(value).toFixed(2).replace(".", ",") + " " + currency;
|
||||
}
|
||||
|
||||
// Berechnet den Zeilenpreis (Menge * Preis * Rabatt)
|
||||
function getRowAmount(row: any) {
|
||||
const price = Number(row.price || 0);
|
||||
const quantity = Number(row.quantity || 0);
|
||||
const discount = Number(row.discountPercent || 0);
|
||||
return quantity * price * (1 - discount / 100);
|
||||
}
|
||||
|
||||
// Berechnet alle Summen (Netto, Brutto, Steuern, Titel-Summen)
|
||||
// Dies ersetzt 'documentTotal.value' aus dem Frontend
|
||||
function calculateDocumentTotals(rows: any[], taxType: string) {
|
||||
console.log(rows);
|
||||
|
||||
let totalNet = 0;
|
||||
let totalNet19 = 0;
|
||||
let totalNet7 = 0;
|
||||
let totalNet0 = 0;
|
||||
let titleSums: Record<string, number> = {};
|
||||
|
||||
// Aktueller Titel für Gruppierung
|
||||
let currentTitle = "";
|
||||
|
||||
rows.forEach(row => {
|
||||
if (row.mode === 'title') {
|
||||
currentTitle = row.text || row.description || "Titel";
|
||||
if (!titleSums[currentTitle]) titleSums[currentTitle] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (['normal', 'service', 'free'].includes(row.mode)) {
|
||||
const amount = getRowAmount(row);
|
||||
totalNet += amount;
|
||||
|
||||
// Summen pro Titel addieren
|
||||
//if (!titleSums[currentTitle]) titleSums[currentTitle] = 0;
|
||||
if(currentTitle.length > 0) titleSums[currentTitle] += amount;
|
||||
|
||||
// Steuer-Logik
|
||||
const tax = taxType === "19 UStG" || taxType === "13b UStG" ? 0 : Number(row.taxPercent);
|
||||
|
||||
if (tax === 19) totalNet19 += amount;
|
||||
else if (tax === 7) totalNet7 += amount;
|
||||
else totalNet0 += amount;
|
||||
}
|
||||
});
|
||||
|
||||
const isTaxFree = ["13b UStG", "19 UStG"].includes(taxType);
|
||||
|
||||
const tax19 = isTaxFree ? 0 : totalNet19 * 0.19;
|
||||
const tax7 = isTaxFree ? 0 : totalNet7 * 0.07;
|
||||
const totalGross = totalNet + tax19 + tax7;
|
||||
|
||||
return {
|
||||
totalNet,
|
||||
totalNet19,
|
||||
totalNet7,
|
||||
totalNet0,
|
||||
total19: tax19,
|
||||
total7: tax7,
|
||||
total0: 0,
|
||||
totalGross,
|
||||
titleSums // Gibt ein Objekt zurück: { "Titel A": 150.00, "Titel B": 200.00 }
|
||||
};
|
||||
}
|
||||
|
||||
export function getDocumentDataBackend(
|
||||
itemInfo: any, // Das Dokument objekt (createddocument)
|
||||
tenant: any, // Tenant Infos (auth.activeTenantData)
|
||||
customerData: any, // Geladener Kunde
|
||||
contactData: any, // Geladener Kontakt (optional)
|
||||
contactPerson: any, // Geladenes User-Profil (ersetzt den API Call)
|
||||
projectData: any, // Projekt
|
||||
contractData: any, // Vertrag
|
||||
units: any[], // Array aller Einheiten
|
||||
products: any[], // Array aller Produkte
|
||||
services: any[] // Array aller Services
|
||||
) {
|
||||
const businessInfo = tenant.businessInfo || {}; // Fallback falls leer
|
||||
|
||||
// --- 1. Agriculture Logic ---
|
||||
// Prüfen ob 'extraModules' existiert, sonst leeres Array annehmen
|
||||
const modules = tenant.extraModules || [];
|
||||
if (modules.includes("agriculture")) {
|
||||
itemInfo.rows.forEach((row: any) => {
|
||||
if (row.agriculture && row.agriculture.dieselUsage) {
|
||||
row.agriculture.description = `${row.agriculture.dieselUsage} L Diesel zu ${renderCurrency(row.agriculture.dieselPrice)}/L verbraucht ${row.description ? "\n" + row.description : ""}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2. Tax Override Logic ---
|
||||
let rows = JSON.parse(JSON.stringify(itemInfo.rows)); // Deep Clone um Original nicht zu mutieren
|
||||
if (itemInfo.taxType === "13b UStG" || itemInfo.taxType === "19 UStG") {
|
||||
rows = rows.map((row: any) => ({ ...row, taxPercent: 0 }));
|
||||
}
|
||||
|
||||
// --- 4. Berechnungen (Ersetzt Vue computed props) ---
|
||||
const totals = calculateDocumentTotals(rows, itemInfo.taxType);
|
||||
|
||||
console.log(totals);
|
||||
|
||||
// --- 3. Rows Mapping & Processing ---
|
||||
rows = rows.map((row: any) => {
|
||||
const unit = units.find(i => i.id === row.unit) || { short: "" };
|
||||
|
||||
// Description Text Logic
|
||||
if (!['pagebreak', 'title'].includes(row.mode)) {
|
||||
if (row.agriculture && row.agriculture.description) {
|
||||
row.descriptionText = row.agriculture.description;
|
||||
} else if (row.description) {
|
||||
row.descriptionText = row.description;
|
||||
} else {
|
||||
delete row.descriptionText;
|
||||
}
|
||||
}
|
||||
|
||||
// Product/Service Name Resolution
|
||||
if (!['pagebreak', 'title', 'text'].includes(row.mode)) {
|
||||
if (row.mode === 'normal') {
|
||||
const prod = products.find(i => i.id === row.product);
|
||||
if (prod) row.text = prod.name;
|
||||
}
|
||||
if (row.mode === 'service') {
|
||||
const serv = services.find(i => i.id === row.service);
|
||||
if (serv) row.text = serv.name;
|
||||
}
|
||||
|
||||
const rowAmount = getRowAmount(row);
|
||||
|
||||
return {
|
||||
...row,
|
||||
rowAmount: renderCurrency(rowAmount),
|
||||
quantity: String(row.quantity).replace(".", ","),
|
||||
unit: unit.short,
|
||||
pos: String(row.pos),
|
||||
price: renderCurrency(row.price),
|
||||
discountText: row.discountPercent > 0 ? `(Rabatt: ${row.discountPercent} %)` : ""
|
||||
};
|
||||
} else {
|
||||
return row;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// --- 5. Handlebars Context ---
|
||||
const generateContext = () => {
|
||||
return {
|
||||
// lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen
|
||||
anrede: (contactData && contactData.salutation) || (customerData && customerData.salutation),
|
||||
titel: (contactData && contactData.title) || (customerData && customerData.title),
|
||||
vorname: (contactData && contactData.firstName) || (customerData && customerData.firstname), // Achte auf casing (firstName vs firstname) in deiner DB
|
||||
nachname: (contactData && contactData.lastName) || (customerData && customerData.lastname),
|
||||
kundenname: customerData && customerData.name,
|
||||
zahlungsziel_in_tagen: itemInfo.paymentDays,
|
||||
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
|
||||
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null
|
||||
};
|
||||
};
|
||||
|
||||
const templateStartText = Handlebars.compile(itemInfo.startText || "");
|
||||
const templateEndText = Handlebars.compile(itemInfo.endText || "");
|
||||
|
||||
// --- 6. Title Sums Formatting ---
|
||||
let returnTitleSums: Record<string, string> = {};
|
||||
Object.keys(totals.titleSums).forEach(key => {
|
||||
returnTitleSums[key] = renderCurrency(totals.titleSums[key]);
|
||||
});
|
||||
|
||||
// Transfer logic (Falls nötig, hier vereinfacht)
|
||||
let returnTitleSumsTransfer = { ...returnTitleSums };
|
||||
|
||||
// --- 7. Construct Final Object ---
|
||||
|
||||
// Adresse aufbereiten
|
||||
const recipientArray = [
|
||||
customerData.name,
|
||||
...(customerData.nameAddition ? [customerData.nameAddition] : []),
|
||||
...(contactData ? [`${contactData.firstName} ${contactData.lastName}`] : []),
|
||||
itemInfo.address?.street || customerData.street || "",
|
||||
...(itemInfo.address?.special ? [itemInfo.address.special] : []),
|
||||
`${itemInfo.address?.zip || customerData.zip} ${itemInfo.address?.city || customerData.city}`,
|
||||
].filter(Boolean); // Leere Einträge entfernen
|
||||
|
||||
console.log(contactPerson)
|
||||
|
||||
// Info Block aufbereiten
|
||||
const infoBlock = [
|
||||
{
|
||||
label: itemInfo.documentNumberTitle || "Rechnungsnummer",
|
||||
content: itemInfo.documentNumber || "ENTWURF",
|
||||
}, {
|
||||
label: "Kundennummer",
|
||||
content: customerData.customerNumber,
|
||||
}, {
|
||||
label: "Belegdatum",
|
||||
content: itemInfo.documentDate ? dayjs(itemInfo.documentDate).format("DD.MM.YYYY") : dayjs().format("DD.MM.YYYY"),
|
||||
},
|
||||
// Lieferdatum Logik
|
||||
...(itemInfo.deliveryDateType !== "Kein Lieferdatum anzeigen" ? [{
|
||||
label: itemInfo.deliveryDateType || "Lieferdatum",
|
||||
content: !['Lieferzeitraum', 'Leistungszeitraum'].includes(itemInfo.deliveryDateType)
|
||||
? (itemInfo.deliveryDate ? dayjs(itemInfo.deliveryDate).format("DD.MM.YYYY") : "")
|
||||
: `${itemInfo.deliveryDate ? dayjs(itemInfo.deliveryDate).format("DD.MM.YYYY") : ""} - ${itemInfo.deliveryDateEnd ? dayjs(itemInfo.deliveryDateEnd).format("DD.MM.YYYY") : ""}`,
|
||||
}] : []),
|
||||
{
|
||||
label: "Ansprechpartner",
|
||||
content: contactPerson ? (contactPerson.name || contactPerson.full_name || contactPerson.email) : "-",
|
||||
},
|
||||
// Kontakt Infos
|
||||
...((itemInfo.contactTel || contactPerson?.fixed_tel || contactPerson?.mobile_tel) ? [{
|
||||
label: "Telefon",
|
||||
content: itemInfo.contactTel || contactPerson?.fixed_tel || contactPerson?.mobile_tel,
|
||||
}] : []),
|
||||
...(contactPerson?.email ? [{
|
||||
label: "E-Mail",
|
||||
content: contactPerson.email,
|
||||
}] : []),
|
||||
// Objekt / Projekt / Vertrag
|
||||
...(itemInfo.plant ? [{ label: "Objekt", content: "Objekt Name" }] : []), // Hier müsstest du Plant Data übergeben wenn nötig
|
||||
...(projectData ? [{ label: "Projekt", content: projectData.name }] : []),
|
||||
...(contractData ? [{ label: "Vertrag", content: contractData.contractNumber }] : [])
|
||||
];
|
||||
|
||||
// Total Array für PDF Footer
|
||||
const totalArray = [
|
||||
{
|
||||
label: "Nettobetrag",
|
||||
content: renderCurrency(totals.totalNet),
|
||||
},
|
||||
...(totals.totalNet19 > 0 && !["13b UStG"].includes(itemInfo.taxType) ? [{
|
||||
label: `zzgl. 19% USt auf ${renderCurrency(totals.totalNet19)}`,
|
||||
content: renderCurrency(totals.total19),
|
||||
}] : []),
|
||||
...(totals.totalNet7 > 0 && !["13b UStG"].includes(itemInfo.taxType) ? [{
|
||||
label: `zzgl. 7% USt auf ${renderCurrency(totals.totalNet7)}`,
|
||||
content: renderCurrency(totals.total7),
|
||||
}] : []),
|
||||
{
|
||||
label: "Gesamtbetrag",
|
||||
content: renderCurrency(totals.totalGross),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
...itemInfo,
|
||||
type: itemInfo.type,
|
||||
taxType: itemInfo.taxType,
|
||||
adressLine: `${businessInfo.name || ''}, ${businessInfo.street || ''}, ${businessInfo.zip || ''} ${businessInfo.city || ''}`,
|
||||
recipient: recipientArray,
|
||||
info: infoBlock,
|
||||
title: itemInfo.title,
|
||||
description: itemInfo.description,
|
||||
// Handlebars Compilation ausführen
|
||||
endText: templateEndText(generateContext()),
|
||||
startText: templateStartText(generateContext()),
|
||||
rows: rows,
|
||||
totalArray: totalArray,
|
||||
total: {
|
||||
totalNet: renderCurrency(totals.totalNet),
|
||||
total19: renderCurrency(totals.total19),
|
||||
total0: renderCurrency(totals.total0), // 0% USt Zeilen
|
||||
totalGross: renderCurrency(totals.totalGross),
|
||||
// Diese Werte existieren im einfachen Backend-Kontext oft nicht (Zahlungen checken), daher 0 oder Logik bauen
|
||||
totalGrossAlreadyPaid: renderCurrency(0),
|
||||
totalSumToPay: renderCurrency(totals.totalGross),
|
||||
titleSums: returnTitleSums,
|
||||
titleSumsTransfer: returnTitleSumsTransfer
|
||||
},
|
||||
agriculture: itemInfo.agriculture,
|
||||
// Falls du AdvanceInvoices brauchst, musst du die Objekte hier übergeben oder leer lassen
|
||||
usedAdvanceInvoices: []
|
||||
};
|
||||
}
|
||||
229
backend/src/modules/time/buildtimeevaluation.service.ts
Normal file
229
backend/src/modules/time/buildtimeevaluation.service.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// src/services/buildTimeEvaluationFromSpans.ts
|
||||
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { and, eq, gte, lte, inArray } from "drizzle-orm";
|
||||
import { authProfiles, holidays } from "../../../db/schema";
|
||||
import { DerivedSpan } from "./derivetimespans.service"; // Importiert den angereicherten Span-Typ
|
||||
|
||||
// Definiert das erwartete Rückgabeformat
|
||||
export type TimeEvaluationResult = {
|
||||
user_id: string;
|
||||
tenant_id: number;
|
||||
from: string;
|
||||
to: string;
|
||||
|
||||
// Sollzeit
|
||||
timeSpanWorkingMinutes: number;
|
||||
|
||||
// Arbeitszeit Salden
|
||||
sumWorkingMinutesSubmitted: number;
|
||||
sumWorkingMinutesApproved: number;
|
||||
|
||||
// Abwesenheiten (minuten und Tage)
|
||||
sumWorkingMinutesRecreationDays: number;
|
||||
sumRecreationDays: number;
|
||||
sumWorkingMinutesVacationDays: number;
|
||||
sumVacationDays: number;
|
||||
sumWorkingMinutesSickDays: number;
|
||||
sumSickDays: number;
|
||||
|
||||
// Endsalden
|
||||
saldoApproved: number; // Saldo basierend auf genehmigter Zeit
|
||||
saldoSubmitted: number; // Saldo basierend auf eingereichter/genehmigter Zeit
|
||||
|
||||
spans: DerivedSpan[];
|
||||
};
|
||||
|
||||
// Hilfsfunktion zur Berechnung der Minuten (nur für geschlossene Spannen)
|
||||
const calcMinutes = (start: Date, end: Date | null): number => {
|
||||
if (!end) return 0;
|
||||
return (end.getTime() - start.getTime()) / 60000;
|
||||
};
|
||||
|
||||
|
||||
export async function buildTimeEvaluationFromSpans(
|
||||
server: FastifyInstance,
|
||||
user_id: string,
|
||||
tenant_id: number,
|
||||
startDateInput: string,
|
||||
endDateInput: string,
|
||||
// Der wichtigste Unterschied: Wir nehmen die angereicherten Spannen als Input
|
||||
spans: DerivedSpan[]
|
||||
): Promise<TimeEvaluationResult> {
|
||||
|
||||
const startDate = server.dayjs(startDateInput);
|
||||
const endDate = server.dayjs(endDateInput);
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 1️⃣ Profil und Feiertage laden (WIE IM ALTEN SERVICE)
|
||||
// -------------------------------------------------------------
|
||||
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.user_id, user_id),
|
||||
eq(authProfiles.tenant_id, tenant_id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const profile = profileRows[0];
|
||||
if (!profile) throw new Error("Profil konnte nicht geladen werden.");
|
||||
|
||||
const holidaysRows = await server.db
|
||||
.select({
|
||||
date: holidays.date,
|
||||
})
|
||||
.from(holidays)
|
||||
.where(
|
||||
and(
|
||||
inArray(holidays.state_code, [profile.state_code, "DE"]),
|
||||
gte(holidays.date, startDate.format("YYYY-MM-DD")),
|
||||
lte(holidays.date, endDate.add(1, "day").format("YYYY-MM-DD"))
|
||||
)
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 2️⃣ Sollzeit berechnen (WIE IM ALTEN SERVICE)
|
||||
// -------------------------------------------------------------
|
||||
let timeSpanWorkingMinutes = 0;
|
||||
const totalDays = endDate.add(1, "day").diff(startDate, "days");
|
||||
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
const date = startDate.add(i, "days");
|
||||
const weekday = date.day();
|
||||
timeSpanWorkingMinutes +=
|
||||
(profile.weekly_regular_working_hours?.[weekday] || 0) * 60;
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3️⃣ Arbeits- und Abwesenheitszeiten berechnen (NEUE LOGIK)
|
||||
// -------------------------------------------------------------
|
||||
|
||||
let sumWorkingMinutesSubmitted = 0;
|
||||
let sumWorkingMinutesApproved = 0;
|
||||
|
||||
let sumWorkingMinutesVacationDays = 0;
|
||||
let sumVacationDays = 0;
|
||||
let sumWorkingMinutesSickDays = 0;
|
||||
let sumSickDays = 0;
|
||||
|
||||
// Akkumulieren der Zeiten basierend auf dem abgeleiteten Typ und Status
|
||||
for (const span of spans) {
|
||||
|
||||
// **A. Arbeitszeiten (WORK)**
|
||||
if (span.type === "work") {
|
||||
const minutes = calcMinutes(span.startedAt, span.endedAt);
|
||||
|
||||
// Zähle zur eingereichten Summe, wenn der Status submitted oder approved ist
|
||||
if (span.status === "submitted" || span.status === "approved") {
|
||||
sumWorkingMinutesSubmitted += minutes;
|
||||
}
|
||||
|
||||
// Zähle zur genehmigten Summe, wenn der Status approved ist
|
||||
if (span.status === "approved") {
|
||||
sumWorkingMinutesApproved += minutes;
|
||||
}
|
||||
}
|
||||
|
||||
// **B. Abwesenheiten (VACATION, SICK)**
|
||||
// Wir verwenden die Logik aus dem alten Service: Berechnung der Sollzeit
|
||||
// basierend auf den Tagen der Span (Voraussetzung: Spannen sind Volltages-Spannen)
|
||||
if (span.type === "vacation" || span.type === "sick") {
|
||||
|
||||
// Behandle nur genehmigte Abwesenheiten für die Saldenberechnung
|
||||
if (span.status !== "approved") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startDay = server.dayjs(span.startedAt).startOf('day');
|
||||
// Wenn endedAt null ist (offene Span), nehmen wir das Ende des Zeitraums
|
||||
const endDay = span.endedAt ? server.dayjs(span.endedAt).startOf('day') : endDate.startOf('day');
|
||||
|
||||
// Berechnung der Tage der Span
|
||||
const days = endDay.diff(startDay, "day") + 1;
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const day = startDay.add(i, "day");
|
||||
const weekday = day.day();
|
||||
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
|
||||
|
||||
if (span.type === "vacation") {
|
||||
sumWorkingMinutesVacationDays += hours * 60;
|
||||
} else if (span.type === "sick") {
|
||||
sumWorkingMinutesSickDays += hours * 60;
|
||||
}
|
||||
}
|
||||
|
||||
if (span.type === "vacation") {
|
||||
sumVacationDays += days;
|
||||
} else if (span.type === "sick") {
|
||||
sumSickDays += days;
|
||||
}
|
||||
}
|
||||
|
||||
// PAUSE Spannen werden ignoriert, da sie in der faktischen Ableitung bereits von WORK abgezogen wurden.
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 4️⃣ Feiertagsausgleich (WIE IM ALTEN SERVICE)
|
||||
// -------------------------------------------------------------
|
||||
let sumWorkingMinutesRecreationDays = 0;
|
||||
let sumRecreationDays = 0;
|
||||
|
||||
if (profile.recreation_days_compensation && holidaysRows?.length) {
|
||||
holidaysRows.forEach(({ date }) => {
|
||||
const weekday = server.dayjs(date).day();
|
||||
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
|
||||
sumWorkingMinutesRecreationDays += hours * 60;
|
||||
sumRecreationDays++;
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 5️⃣ Salden berechnen (NEUE LOGIK)
|
||||
// -------------------------------------------------------------
|
||||
|
||||
const totalCompensatedMinutes =
|
||||
sumWorkingMinutesRecreationDays +
|
||||
sumWorkingMinutesVacationDays +
|
||||
sumWorkingMinutesSickDays;
|
||||
|
||||
// Saldo basierend auf GENEHMIGTER Arbeitszeit
|
||||
const totalApprovedMinutes = sumWorkingMinutesApproved + totalCompensatedMinutes;
|
||||
const saldoApproved = totalApprovedMinutes - timeSpanWorkingMinutes;
|
||||
|
||||
// Saldo basierend auf EINGEREICHTER und GENEHMIGTER Arbeitszeit
|
||||
const totalSubmittedMinutes = sumWorkingMinutesSubmitted + totalCompensatedMinutes;
|
||||
const saldoSubmitted = totalSubmittedMinutes - timeSpanWorkingMinutes;
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 6️⃣ Rückgabe
|
||||
// -------------------------------------------------------------
|
||||
return {
|
||||
user_id,
|
||||
tenant_id,
|
||||
from: startDate.format("YYYY-MM-DD"),
|
||||
to: endDate.format("YYYY-MM-DD"),
|
||||
timeSpanWorkingMinutes,
|
||||
|
||||
sumWorkingMinutesSubmitted,
|
||||
sumWorkingMinutesApproved,
|
||||
|
||||
sumWorkingMinutesRecreationDays,
|
||||
sumRecreationDays,
|
||||
sumWorkingMinutesVacationDays,
|
||||
sumVacationDays,
|
||||
sumWorkingMinutesSickDays,
|
||||
sumSickDays,
|
||||
|
||||
saldoApproved,
|
||||
saldoSubmitted,
|
||||
spans,
|
||||
};
|
||||
}
|
||||
165
backend/src/modules/time/derivetimespans.service.ts
Normal file
165
backend/src/modules/time/derivetimespans.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
type State = "IDLE" | "WORKING" | "PAUSED" | "ABSENT";
|
||||
|
||||
export type SpanStatus = "factual" | "submitted" | "approved" | "rejected";
|
||||
|
||||
export type DerivedSpan = {
|
||||
type: "work" | "pause" | "vacation" | "sick" | "overtime_compensation";
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
sourceEventIds: string[];
|
||||
status: SpanStatus;
|
||||
statusActorId?: string;
|
||||
};
|
||||
|
||||
type TimeEvent = {
|
||||
id: string;
|
||||
eventtype: string;
|
||||
eventtime: Date;
|
||||
};
|
||||
|
||||
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
|
||||
const FACTUAL_EVENT_TYPES = new Set([
|
||||
"work_start",
|
||||
"pause_start",
|
||||
"pause_end",
|
||||
"work_end",
|
||||
"auto_stop", // Wird als work_end behandelt
|
||||
"vacation_start",
|
||||
"vacation_end",
|
||||
"sick_start",
|
||||
"sick_end",
|
||||
"overtime_compensation_start",
|
||||
"overtime_compensation_end",
|
||||
]);
|
||||
|
||||
export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
||||
|
||||
// 1. FILTERN: Nur faktische Events verarbeiten
|
||||
const events = allValidEvents.filter(event =>
|
||||
FACTUAL_EVENT_TYPES.has(event.eventtype)
|
||||
);
|
||||
|
||||
const spans: DerivedSpan[] = [];
|
||||
|
||||
let state: State = "IDLE";
|
||||
let currentStart: Date | null = null;
|
||||
let currentType: DerivedSpan["type"] | null = null;
|
||||
let sourceEventIds: string[] = [];
|
||||
|
||||
const closeSpan = (end: Date) => {
|
||||
if (!currentStart || !currentType) return;
|
||||
|
||||
spans.push({
|
||||
type: currentType,
|
||||
startedAt: currentStart,
|
||||
endedAt: end,
|
||||
sourceEventIds: [...sourceEventIds],
|
||||
// Standardstatus ist "factual", wird später angereichert
|
||||
status: "factual"
|
||||
});
|
||||
|
||||
currentStart = null;
|
||||
currentType = null;
|
||||
sourceEventIds = [];
|
||||
};
|
||||
|
||||
const closeOpenSpanAsRunning = () => {
|
||||
if (!currentStart || !currentType) return;
|
||||
|
||||
spans.push({
|
||||
type: currentType,
|
||||
startedAt: currentStart,
|
||||
endedAt: null,
|
||||
sourceEventIds: [...sourceEventIds],
|
||||
// Standardstatus ist "factual", wird später angereichert
|
||||
status: "factual"
|
||||
});
|
||||
|
||||
currentStart = null;
|
||||
currentType = null;
|
||||
sourceEventIds = [];
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
sourceEventIds.push(event.id);
|
||||
|
||||
switch (event.eventtype) {
|
||||
/* =========================
|
||||
ARBEITSZEIT
|
||||
========================= */
|
||||
|
||||
case "work_start":
|
||||
if (state === "WORKING" || state === "PAUSED" || state === "ABSENT") {
|
||||
// Schließt die vorherige Spanne (falls z.B. work_start nach sick_start kommt)
|
||||
closeSpan(event.eventtime);
|
||||
}
|
||||
state = "WORKING";
|
||||
currentStart = event.eventtime;
|
||||
currentType = "work";
|
||||
break;
|
||||
|
||||
case "pause_start":
|
||||
if (state === "WORKING") {
|
||||
closeSpan(event.eventtime);
|
||||
state = "PAUSED";
|
||||
currentStart = event.eventtime;
|
||||
currentType = "pause";
|
||||
}
|
||||
break;
|
||||
|
||||
case "pause_end":
|
||||
if (state === "PAUSED") {
|
||||
closeSpan(event.eventtime);
|
||||
state = "WORKING";
|
||||
currentStart = event.eventtime;
|
||||
currentType = "work";
|
||||
}
|
||||
break;
|
||||
|
||||
case "work_end":
|
||||
case "auto_stop":
|
||||
if (state === "WORKING" || state === "PAUSED") {
|
||||
closeSpan(event.eventtime);
|
||||
}
|
||||
state = "IDLE";
|
||||
break;
|
||||
|
||||
/* =========================
|
||||
ABWESENHEITEN
|
||||
========================= */
|
||||
|
||||
case "vacation_start":
|
||||
case "sick_start":
|
||||
case "overtime_compensation_start":
|
||||
// Mappt den Event-Typ direkt auf den Span-Typ
|
||||
const newType = event.eventtype.split('_')[0] as DerivedSpan["type"];
|
||||
|
||||
if (state !== "IDLE") {
|
||||
closeSpan(event.eventtime);
|
||||
}
|
||||
state = "ABSENT";
|
||||
currentStart = event.eventtime;
|
||||
currentType = newType;
|
||||
break;
|
||||
|
||||
case "vacation_end":
|
||||
case "sick_end":
|
||||
case "overtime_compensation_end":
|
||||
// Extrahiert den Typ der zu beendenden Spanne
|
||||
const endedType = event.eventtype.split('_')[0] as DerivedSpan["type"];
|
||||
|
||||
if (state === "ABSENT" && currentType === endedType) {
|
||||
closeSpan(event.eventtime);
|
||||
}
|
||||
state = "IDLE";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 WICHTIG: Offene Spannen als laufend zurückgeben
|
||||
if (state !== "IDLE") {
|
||||
closeOpenSpanAsRunning();
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// src/services/enrichSpansWithStatus.ts (Korrigierte Version)
|
||||
|
||||
import { DerivedSpan, SpanStatus } from "./derivetimespans.service";
|
||||
import { TimeEvent } from "./loadvalidevents.service"; // Jetzt mit related_event_id und actoruser_id
|
||||
|
||||
// ... (Rest der Imports)
|
||||
|
||||
export function enrichSpansWithStatus(
|
||||
factualSpans: DerivedSpan[],
|
||||
allValidEvents: TimeEvent[]
|
||||
): DerivedSpan[] {
|
||||
|
||||
// 1. Map der administrativen Aktionen erstellen
|
||||
const eventStatusMap = new Map<string, { status: SpanStatus, actorId: string }>();
|
||||
|
||||
const administrativeEvents = allValidEvents.filter(e =>
|
||||
e.eventtype === 'submitted' || e.eventtype === 'approved' || e.eventtype === 'rejected'
|
||||
);
|
||||
|
||||
// allValidEvents ist nach Zeit sortiert
|
||||
for (const event of administrativeEvents) {
|
||||
|
||||
// **Verwendung des expliziten Feldes**
|
||||
const relatedId = event.related_event_id;
|
||||
const actorId = event.actoruser_id;
|
||||
|
||||
if (relatedId) { // Nur fortfahren, wenn ein Bezugs-Event existiert
|
||||
|
||||
let status: SpanStatus = "factual";
|
||||
// Wir überschreiben den Status des relatedId basierend auf der Event-Historie
|
||||
if (event.eventtype === 'submitted') status = 'submitted';
|
||||
else if (event.eventtype === 'approved') status = 'approved';
|
||||
else if (event.eventtype === 'rejected') status = 'rejected';
|
||||
|
||||
eventStatusMap.set(relatedId, { status, actorId });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Status der Spannen bestimmen und anreichern
|
||||
return factualSpans.map(span => {
|
||||
|
||||
let approvedCount = 0;
|
||||
let rejectedCount = 0;
|
||||
let submittedCount = 0;
|
||||
let isFactualCount = 0;
|
||||
|
||||
for (const sourceId of span.sourceEventIds) {
|
||||
const statusInfo = eventStatusMap.get(sourceId);
|
||||
|
||||
if (statusInfo) {
|
||||
// Ein faktisches Event kann durch mehrere administrative Events betroffen sein
|
||||
// Wir speichern im Map nur den letzten Status (z.B. approved überschreibt submitted)
|
||||
if (statusInfo.status === 'approved') approvedCount++;
|
||||
else if (statusInfo.status === 'rejected') rejectedCount++;
|
||||
else if (statusInfo.status === 'submitted') submittedCount++;
|
||||
} else {
|
||||
// Wenn kein administratives Event existiert
|
||||
isFactualCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Regel zur Bestimmung des Span-Status:
|
||||
const totalSourceEvents = span.sourceEventIds.length;
|
||||
let finalStatus: SpanStatus = "factual";
|
||||
|
||||
if (totalSourceEvents > 0) {
|
||||
|
||||
// Priorität 1: Rejection
|
||||
if (rejectedCount > 0) {
|
||||
finalStatus = "rejected";
|
||||
}
|
||||
// Priorität 2: Full Approval
|
||||
else if (approvedCount === totalSourceEvents) {
|
||||
finalStatus = "approved";
|
||||
}
|
||||
// Priorität 3: Submitted (wenn nicht fully approved oder rejected, aber mindestens eines submitted ist)
|
||||
else if (submittedCount > 0 || approvedCount > 0) {
|
||||
finalStatus = "submitted";
|
||||
// Ein Span ist submitted, wenn es zumindest teilweise eingereicht (oder genehmigt) ist,
|
||||
// aber nicht alle Events den finalen Status "approved" haben.
|
||||
}
|
||||
// Ansonsten bleibt es "factual" (wenn z.B. nur work_start aber nicht work_end eingereicht wurde, oder nichts)
|
||||
}
|
||||
|
||||
// Rückgabe der angereicherten Span
|
||||
return {
|
||||
...span,
|
||||
status: finalStatus,
|
||||
};
|
||||
});
|
||||
}
|
||||
232
backend/src/modules/time/evaluation.service.ts
Normal file
232
backend/src/modules/time/evaluation.service.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {and, eq, gte, lte, asc, inArray} from "drizzle-orm";
|
||||
import {
|
||||
authProfiles,
|
||||
stafftimeentries,
|
||||
holidays,
|
||||
} from "../../../db/schema";
|
||||
|
||||
export async function generateTimesEvaluation(
|
||||
server: FastifyInstance,
|
||||
user_id: string,
|
||||
tenant_id: number,
|
||||
startDateInput: string,
|
||||
endDateInput: string
|
||||
) {
|
||||
const startDate = server.dayjs(startDateInput);
|
||||
const endDate = server.dayjs(endDateInput);
|
||||
|
||||
console.log(startDate.format("YYYY-MM-DD HH:mm:ss"));
|
||||
console.log(endDate.format("YYYY-MM-DD HH:mm:ss"));
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 1️⃣ Profil laden
|
||||
// -------------------------------------------------------------
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.user_id, user_id),
|
||||
eq(authProfiles.tenant_id, tenant_id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const profile = profileRows[0];
|
||||
|
||||
if (!profile) throw new Error("Profil konnte nicht geladen werden.");
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 2️⃣ Arbeitszeiten laden
|
||||
// -------------------------------------------------------------
|
||||
const timesRaw = await server.db
|
||||
.select()
|
||||
.from(stafftimeentries)
|
||||
.where(
|
||||
and(
|
||||
eq(stafftimeentries.tenant_id, tenant_id),
|
||||
eq(stafftimeentries.user_id, user_id)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(stafftimeentries.started_at));
|
||||
|
||||
const isBetween = (spanStartDate, spanEndDate, startDate, endDate) => {
|
||||
return (
|
||||
server
|
||||
.dayjs(startDate)
|
||||
.isBetween(spanStartDate, spanEndDate, "day", "[]") &&
|
||||
server
|
||||
.dayjs(endDate)
|
||||
.isBetween(spanStartDate, spanEndDate, "day", "[]")
|
||||
);
|
||||
};
|
||||
|
||||
const times = timesRaw.filter((i) =>
|
||||
isBetween(startDate, endDate, i.started_at, i.stopped_at)
|
||||
);
|
||||
|
||||
console.log(times);
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3️⃣ Feiertage laden
|
||||
// -------------------------------------------------------------
|
||||
const holidaysRows = await server.db
|
||||
.select({
|
||||
date: holidays.date,
|
||||
})
|
||||
.from(holidays)
|
||||
.where(
|
||||
and(
|
||||
inArray(holidays.state_code, [profile.state_code, "DE"]),
|
||||
gte(holidays.date, startDate.format("YYYY-MM-DD")),
|
||||
lte(holidays.date, endDate.add(1, "day").format("YYYY-MM-DD"))
|
||||
)
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 4️⃣ Sollzeit berechnen
|
||||
// -------------------------------------------------------------
|
||||
let timeSpanWorkingMinutes = 0;
|
||||
const totalDays = endDate.add(1, "day").diff(startDate, "days");
|
||||
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
const date = startDate.add(i, "days");
|
||||
const weekday = date.day();
|
||||
timeSpanWorkingMinutes +=
|
||||
(profile.weekly_regular_working_hours?.[weekday] || 0) * 60;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 5️⃣ Eingereicht/genehmigt
|
||||
// -------------------------------------------------------------
|
||||
const calcMinutes = (start: string, end: string | null) =>
|
||||
server.dayjs(end || new Date()).diff(server.dayjs(start), "minutes");
|
||||
|
||||
let sumWorkingMinutesEingereicht = 0;
|
||||
let sumWorkingMinutesApproved = 0;
|
||||
|
||||
for (const t of times) {
|
||||
// @ts-ignore
|
||||
const minutes = calcMinutes(t.started_at, t.stopped_at);
|
||||
|
||||
if (["submitted", "approved"].includes(t.state) && t.type === "work") {
|
||||
sumWorkingMinutesEingereicht += minutes;
|
||||
}
|
||||
if (t.state === "approved" && t.type === "work") {
|
||||
sumWorkingMinutesApproved += minutes;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 6️⃣ Feiertagsausgleich
|
||||
// -------------------------------------------------------------
|
||||
let sumWorkingMinutesRecreationDays = 0;
|
||||
let sumRecreationDays = 0;
|
||||
|
||||
if (profile.recreation_days_compensation && holidaysRows?.length) {
|
||||
holidaysRows.forEach(({ date }) => {
|
||||
const weekday = server.dayjs(date).day();
|
||||
const hours = profile.weekly_regular_working_hours?.[weekday] || 0;
|
||||
sumWorkingMinutesRecreationDays += hours * 60;
|
||||
sumRecreationDays++;
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 7️⃣ Urlaub
|
||||
// -------------------------------------------------------------
|
||||
let sumWorkingMinutesVacationDays = 0;
|
||||
let sumVacationDays = 0;
|
||||
|
||||
times
|
||||
.filter((t) => t.type === "vacation" && t.state === "approved")
|
||||
.forEach((time) => {
|
||||
// Tippfehler aus Original: startet_at vs started_at → NICHT korrigiert
|
||||
const days =
|
||||
server.dayjs(time.stopped_at).diff(
|
||||
//@ts-ignore
|
||||
server.dayjs(time.startet_at),
|
||||
"day"
|
||||
) + 1;
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const weekday = server
|
||||
.dayjs(time.started_at)
|
||||
.add(i, "day")
|
||||
.day();
|
||||
const hours =
|
||||
profile.weekly_regular_working_hours?.[weekday] || 0;
|
||||
sumWorkingMinutesVacationDays += hours * 60;
|
||||
}
|
||||
sumVacationDays += days;
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 8️⃣ Krankheit
|
||||
// -------------------------------------------------------------
|
||||
let sumWorkingMinutesSickDays = 0;
|
||||
let sumSickDays = 0;
|
||||
|
||||
times
|
||||
.filter((t) => t.type === "sick" && t.state === "approved")
|
||||
.forEach((time) => {
|
||||
const days =
|
||||
server.dayjs(time.stopped_at).diff(
|
||||
//@ts-ignore
|
||||
server.dayjs(time.startet_at),
|
||||
"day"
|
||||
) + 1;
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const weekday = server
|
||||
.dayjs(time.started_at)
|
||||
.add(i, "day")
|
||||
.day();
|
||||
const hours =
|
||||
profile.weekly_regular_working_hours?.[weekday] || 0;
|
||||
sumWorkingMinutesSickDays += hours * 60;
|
||||
}
|
||||
|
||||
sumSickDays += days;
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 9️⃣ Salden
|
||||
// -------------------------------------------------------------
|
||||
const saldo =
|
||||
sumWorkingMinutesApproved +
|
||||
sumWorkingMinutesRecreationDays +
|
||||
sumWorkingMinutesVacationDays +
|
||||
sumWorkingMinutesSickDays -
|
||||
timeSpanWorkingMinutes;
|
||||
|
||||
const saldoInOfficial =
|
||||
sumWorkingMinutesEingereicht +
|
||||
sumWorkingMinutesRecreationDays +
|
||||
sumWorkingMinutesVacationDays +
|
||||
sumWorkingMinutesSickDays -
|
||||
timeSpanWorkingMinutes;
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 🔟 Rückgabe identisch
|
||||
// -------------------------------------------------------------
|
||||
return {
|
||||
user_id,
|
||||
tenant_id,
|
||||
from: startDate.format("YYYY-MM-DD"),
|
||||
to: endDate.format("YYYY-MM-DD"),
|
||||
timeSpanWorkingMinutes,
|
||||
sumWorkingMinutesEingereicht,
|
||||
sumWorkingMinutesApproved,
|
||||
sumWorkingMinutesRecreationDays,
|
||||
sumRecreationDays,
|
||||
sumWorkingMinutesVacationDays,
|
||||
sumVacationDays,
|
||||
sumWorkingMinutesSickDays,
|
||||
sumSickDays,
|
||||
saldo,
|
||||
saldoInOfficial,
|
||||
times,
|
||||
};
|
||||
}
|
||||
105
backend/src/modules/time/loadvalidevents.service.ts
Normal file
105
backend/src/modules/time/loadvalidevents.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// src/services/loadValidEvents.ts
|
||||
|
||||
import { stafftimeevents } from "../../../db/schema";
|
||||
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm";
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
|
||||
|
||||
|
||||
// Die Definition des TimeEvent Typs, der zurückgegeben wird (muss mit dem tatsächlichen Typ übereinstimmen)
|
||||
export type TimeEvent = {
|
||||
id: string;
|
||||
eventtype: string;
|
||||
eventtime: Date;
|
||||
actoruser_id: string;
|
||||
related_event_id: string | null;
|
||||
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen
|
||||
};
|
||||
|
||||
export async function loadValidEvents(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
userId: string,
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<TimeEvent[]> {
|
||||
// Definieren Sie einen Alias für die stafftimeevents Tabelle in der äußeren Abfrage
|
||||
const baseEvents = stafftimeevents;
|
||||
|
||||
// Die Subquery, um alle IDs zu finden, die ungültig gemacht wurden
|
||||
// Wir nennen die innere Tabelle 'invalidatingEvents'
|
||||
const invalidatingEvents = server.db
|
||||
.select({
|
||||
invalidatedId: baseEvents.invalidates_event_id
|
||||
})
|
||||
.from(baseEvents)
|
||||
.as('invalidating_events');
|
||||
|
||||
// Die Hauptabfrage
|
||||
const result = await server.db
|
||||
.select()
|
||||
.from(baseEvents)
|
||||
.where(
|
||||
and(
|
||||
// 1. Tenant und User filtern
|
||||
eq(baseEvents.tenant_id, tenantId),
|
||||
eq(baseEvents.user_id, userId),
|
||||
|
||||
// 2. Zeitbereich filtern (Typensicher)
|
||||
gte(baseEvents.eventtime, from),
|
||||
lte(baseEvents.eventtime, to),
|
||||
|
||||
// 3. WICHTIG: Korrekturen ausschließen (NOT EXISTS)
|
||||
// Schließe jedes Event aus, dessen ID in der Liste der invalidates_event_id erscheint.
|
||||
sql`
|
||||
not exists (
|
||||
select 1
|
||||
from ${stafftimeevents} i
|
||||
where i.invalidates_event_id = ${baseEvents.id}
|
||||
)
|
||||
`
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
|
||||
baseEvents.eventtime,
|
||||
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
|
||||
baseEvents.id
|
||||
);
|
||||
|
||||
// Mapping auf den sauberen TimeEvent Typ
|
||||
return result.map(e => ({
|
||||
id: e.id,
|
||||
eventtype: e.eventtype,
|
||||
eventtime: e.eventtime,
|
||||
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id)
|
||||
// ...
|
||||
})) as TimeEvent[];
|
||||
}
|
||||
|
||||
export async function loadRelatedAdminEvents(server, eventIds) {
|
||||
if (eventIds.length === 0) return [];
|
||||
|
||||
// Lädt alle administrativen Events, die sich auf die faktischen Event-IDs beziehen
|
||||
const adminEvents = await server.db
|
||||
.select()
|
||||
.from(stafftimeevents)
|
||||
.where(
|
||||
and(
|
||||
inArray(stafftimeevents.related_event_id, eventIds),
|
||||
// Wir müssen hier die Entkräftung prüfen, um z.B. einen abgelehnten submitted-Event auszuschließen
|
||||
sql`
|
||||
not exists (
|
||||
select 1
|
||||
from ${stafftimeevents} i
|
||||
where i.invalidates_event_id = ${stafftimeevents}.id
|
||||
)
|
||||
`,
|
||||
)
|
||||
)
|
||||
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
|
||||
.orderBy(stafftimeevents.eventtime);
|
||||
|
||||
return adminEvents;
|
||||
}
|
||||
51
backend/src/plugins/auth.m2m.ts
Normal file
51
backend/src/plugins/auth.m2m.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { secrets } from "../utils/secrets";
|
||||
|
||||
/**
|
||||
* Fastify Plugin für Machine-to-Machine Authentifizierung.
|
||||
*
|
||||
* Dieses Plugin prüft, ob der Header `x-api-key` vorhanden ist
|
||||
* und mit dem in der .env hinterlegten M2M_API_KEY übereinstimmt.
|
||||
*
|
||||
* Verwendung:
|
||||
* server.register(m2mAuthPlugin, { allowedPrefix: '/internal' })
|
||||
*/
|
||||
export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => {
|
||||
//const allowedPrefix = opts.allowedPrefix || "/internal";
|
||||
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
try {
|
||||
// Nur prüfen, wenn Route unterhalb des Prefix liegt
|
||||
//if (!req.url.startsWith(allowedPrefix)) return;
|
||||
|
||||
const apiKey = req.headers["x-api-key"];
|
||||
|
||||
if (!apiKey || apiKey !== secrets.M2M_API_KEY) {
|
||||
server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`);
|
||||
return reply.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
// Zusatzinformationen im Request (z. B. interne Kennung)
|
||||
(req as any).m2m = {
|
||||
verified: true,
|
||||
type: "internal",
|
||||
key: apiKey,
|
||||
};
|
||||
} catch (err) {
|
||||
// @ts-ignore
|
||||
server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err);
|
||||
return reply.status(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
m2m?: {
|
||||
verified: boolean;
|
||||
type: "internal";
|
||||
key: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
115
backend/src/plugins/auth.ts
Normal file
115
backend/src/plugins/auth.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
import {
|
||||
authUserRoles,
|
||||
authRolePermissions,
|
||||
} from "../../db/schema"
|
||||
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
// 1️⃣ Token aus Header oder Cookie lesen
|
||||
const cookieToken = req.cookies?.token
|
||||
const authHeader = req.headers.authorization
|
||||
|
||||
const headerToken =
|
||||
authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null
|
||||
|
||||
const token =
|
||||
headerToken && headerToken.length > 10
|
||||
? headerToken
|
||||
: cookieToken || null
|
||||
|
||||
if (!token) {
|
||||
return reply.code(401).send({ error: "Authentication required" })
|
||||
}
|
||||
|
||||
try {
|
||||
// 2️⃣ JWT verifizieren
|
||||
const payload = jwt.verify(token, secrets.JWT_SECRET!) as {
|
||||
user_id: string
|
||||
email: string
|
||||
tenant_id: number | null
|
||||
}
|
||||
|
||||
if (!payload?.user_id) {
|
||||
return reply.code(401).send({ error: "Invalid token" })
|
||||
}
|
||||
|
||||
// Payload an Request hängen
|
||||
req.user = payload
|
||||
|
||||
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
||||
if (!req.user.tenant_id) {
|
||||
return
|
||||
}
|
||||
|
||||
const tenantId = req.user.tenant_id
|
||||
const userId = req.user.user_id
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 3️⃣ Rolle des Nutzers im Tenant holen
|
||||
// --------------------------------------------------------
|
||||
const roleRows = await server.db
|
||||
.select()
|
||||
.from(authUserRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(authUserRoles.user_id, userId),
|
||||
eq(authUserRoles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (roleRows.length === 0) {
|
||||
return reply
|
||||
.code(403)
|
||||
.send({ error: "No role assigned for this tenant" })
|
||||
}
|
||||
|
||||
const roleId = roleRows[0].role_id
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 4️⃣ Berechtigungen der Rolle laden
|
||||
// --------------------------------------------------------
|
||||
const permissionRows = await server.db
|
||||
.select()
|
||||
.from(authRolePermissions)
|
||||
.where(eq(authRolePermissions.role_id, roleId))
|
||||
|
||||
const permissions = permissionRows.map((p) => p.permission)
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 5️⃣ An Request hängen für spätere Nutzung
|
||||
// --------------------------------------------------------
|
||||
req.role = roleId
|
||||
req.permissions = permissions
|
||||
req.hasPermission = (perm: string) => permissions.includes(perm)
|
||||
|
||||
} catch (err) {
|
||||
console.error("JWT verification error:", err)
|
||||
return reply.code(401).send({ error: "Invalid or expired token" })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fastify TypeScript Erweiterungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
user: {
|
||||
user_id: string
|
||||
email: string
|
||||
tenant_id: number | null
|
||||
}
|
||||
role: string
|
||||
permissions: string[]
|
||||
hasPermission: (permission: string) => boolean
|
||||
}
|
||||
}
|
||||
22
backend/src/plugins/cors.ts
Normal file
22
backend/src/plugins/cors.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import cors from "@fastify/cors";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
await server.register(cors, {
|
||||
origin: [
|
||||
"http://localhost:3000", // dein Nuxt-Frontend
|
||||
"http://localhost:3001", // dein Nuxt-Frontend
|
||||
"http://127.0.0.1:3000", // dein Nuxt-Frontend
|
||||
"http://192.168.1.227:3001", // dein Nuxt-Frontend
|
||||
"http://192.168.1.227:3000", // dein Nuxt-Frontend
|
||||
"https://beta.fedeo.de", // dein Nuxt-Frontend
|
||||
"https://app.fedeo.de", // dein Nuxt-Frontend
|
||||
"capacitor://localhost", // dein Nuxt-Frontend
|
||||
],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin"],
|
||||
exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
|
||||
credentials: true, // wichtig, falls du Cookies nutzt
|
||||
});
|
||||
});
|
||||
41
backend/src/plugins/dayjs.ts
Normal file
41
backend/src/plugins/dayjs.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import fp from "fastify-plugin"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
// 🧩 Plugins
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat.js";
|
||||
import isBetween from "dayjs/plugin/isBetween.js";
|
||||
import duration from "dayjs/plugin/duration.js";
|
||||
import utc from "dayjs/plugin/utc"
|
||||
import timezone from "dayjs/plugin/timezone"
|
||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter"
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore"
|
||||
import isoWeek from "dayjs/plugin/isoWeek"
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat"
|
||||
|
||||
// 🔧 Erweiterungen aktivieren
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.extend(isSameOrAfter)
|
||||
dayjs.extend(isSameOrBefore)
|
||||
dayjs.extend(isBetween)
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(localizedFormat)
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(isBetween)
|
||||
dayjs.extend(duration)
|
||||
|
||||
/**
|
||||
* Fastify Plugin: hängt dayjs an den Server an
|
||||
*/
|
||||
export default fp(async (server) => {
|
||||
server.decorate("dayjs", dayjs)
|
||||
})
|
||||
|
||||
/**
|
||||
* Typ-Erweiterung für TypeScript
|
||||
*/
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
dayjs: typeof dayjs
|
||||
}
|
||||
}
|
||||
34
backend/src/plugins/db.ts
Normal file
34
backend/src/plugins/db.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import fp from "fastify-plugin"
|
||||
import {drizzle, NodePgDatabase} from "drizzle-orm/node-postgres"
|
||||
import { Pool } from "pg"
|
||||
import * as schema from "../../db/schema"
|
||||
|
||||
export default fp(async (server, opts) => {
|
||||
const pool = new Pool({
|
||||
host: "100.102.185.225",
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
user: "postgres",
|
||||
password: "wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu",
|
||||
database: "fedeo",
|
||||
ssl: process.env.DB_DISABLE_SSL === "true" ? false : undefined,
|
||||
})
|
||||
|
||||
// Drizzle instance
|
||||
const db = drizzle(pool, { schema })
|
||||
|
||||
// Dekorieren -> überall server.db
|
||||
server.decorate("db", db)
|
||||
|
||||
// Graceful Shutdown
|
||||
server.addHook("onClose", async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
server.log.info("Drizzle database connected")
|
||||
})
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
db:NodePgDatabase<typeof schema>
|
||||
}
|
||||
}
|
||||
125
backend/src/plugins/queryconfig.ts
Normal file
125
backend/src/plugins/queryconfig.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import { FastifyPluginAsync, FastifyRequest } from 'fastify'
|
||||
|
||||
export interface QueryConfigPagination {
|
||||
page: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface QueryConfigSort {
|
||||
field: string
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface QueryConfig {
|
||||
pagination: QueryConfigPagination | null
|
||||
sort: QueryConfigSort[]
|
||||
filters: Record<string, string>
|
||||
paginationDisabled: boolean
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
queryConfig: QueryConfig
|
||||
}
|
||||
}
|
||||
|
||||
interface QueryConfigPluginOptions {
|
||||
routes?: string[]
|
||||
}
|
||||
|
||||
function matchRoutePattern(currentPath: string, patterns: string[]): boolean {
|
||||
return patterns.some(pattern => {
|
||||
// Beispiel: /users/:id -> /^\/users\/[^/]+$/
|
||||
const regex = new RegExp(
|
||||
'^' +
|
||||
pattern
|
||||
.replace(/\*/g, '.*') // wildcard
|
||||
.replace(/:[^/]+/g, '[^/]+') +
|
||||
'$'
|
||||
)
|
||||
return regex.test(currentPath)
|
||||
})
|
||||
}
|
||||
|
||||
const queryConfigPlugin: FastifyPluginAsync<QueryConfigPluginOptions> = async (
|
||||
fastify,
|
||||
opts
|
||||
) => {
|
||||
const routePatterns = opts.routes || []
|
||||
|
||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||
const path = req.routeOptions.url || req.raw.url || ''
|
||||
|
||||
if (!matchRoutePattern(path, routePatterns)) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = req.query as Record<string, any>
|
||||
|
||||
console.log(query)
|
||||
|
||||
// Pagination deaktivieren?
|
||||
const disablePagination =
|
||||
query.noPagination === 'true' ||
|
||||
query.pagination === 'false' ||
|
||||
query.limit === '0'
|
||||
|
||||
// Pagination berechnen
|
||||
let pagination: QueryConfigPagination | null = null
|
||||
if (!disablePagination) {
|
||||
const page = Math.max(parseInt(query.page) || 1, 1)
|
||||
const limit = Math.max(parseInt(query.limit) || 25, 1)
|
||||
const offset = (page - 1) * limit
|
||||
pagination = { page, limit, offset }
|
||||
}
|
||||
|
||||
// Sortierung
|
||||
const sort: QueryConfigSort[] = []
|
||||
if (typeof query.sort === 'string') {
|
||||
const items = query.sort.split(',')
|
||||
for (const item of items) {
|
||||
const [field, direction] = item.split(':')
|
||||
sort.push({
|
||||
field: field.trim(),
|
||||
direction: (direction || 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Filterung
|
||||
const filters: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
const match = key.match(/^filter\[(.+)\]$/)
|
||||
if (!match) continue
|
||||
|
||||
const filterKey = match[1]
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Split bei Komma → mehrere Werte
|
||||
const parts = value.split(',').map(v => v.trim()).filter(Boolean)
|
||||
|
||||
// Automatische Typkonvertierung je Element
|
||||
const parsedValues = parts.map(v => {
|
||||
if (v === 'true') return true
|
||||
if (v === 'false') return false
|
||||
if (v === 'null') return null
|
||||
return v
|
||||
})
|
||||
|
||||
filters[filterKey] = parsedValues.length > 1 ? parsedValues : parsedValues[0]
|
||||
}
|
||||
}
|
||||
|
||||
req.queryConfig = {
|
||||
pagination,
|
||||
sort,
|
||||
filters,
|
||||
paginationDisabled: disablePagination
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default fp(queryConfigPlugin, { name: 'query-config' })
|
||||
24
backend/src/plugins/services.ts
Normal file
24
backend/src/plugins/services.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// /plugins/services.ts
|
||||
import fp from "fastify-plugin";
|
||||
import { bankStatementService } from "../modules/cron/bankstatementsync.service";
|
||||
//import {initDokuboxClient, syncDokubox} from "../modules/cron/dokuboximport.service";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {prepareIncomingInvoices} from "../modules/cron/prepareIncomingInvoices";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
services: {
|
||||
bankStatements: ReturnType<typeof bankStatementService>;
|
||||
//dokuboxSync: ReturnType<typeof syncDokubox>;
|
||||
prepareIncomingInvoices: ReturnType<typeof prepareIncomingInvoices>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async function servicePlugin(server: FastifyInstance) {
|
||||
server.decorate("services", {
|
||||
bankStatements: bankStatementService(server),
|
||||
//dokuboxSync: syncDokubox(server),
|
||||
prepareIncomingInvoices: prepareIncomingInvoices(server),
|
||||
});
|
||||
});
|
||||
19
backend/src/plugins/supabase.ts
Normal file
19
backend/src/plugins/supabase.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
import {secrets} from "../utils/secrets";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
const supabaseUrl = secrets.SUPABASE_URL
|
||||
const supabaseServiceKey = secrets.SUPABASE_SERVICE_ROLE_KEY
|
||||
const supabase: SupabaseClient = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Fastify um supabase erweitern
|
||||
server.decorate("supabase", supabase);
|
||||
});
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
supabase: SupabaseClient;
|
||||
}
|
||||
}
|
||||
30
backend/src/plugins/swagger.ts
Normal file
30
backend/src/plugins/swagger.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import swagger from "@fastify/swagger";
|
||||
import swaggerUi from "@fastify/swagger-ui";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
await server.register(swagger, {
|
||||
mode: "dynamic", // wichtig: generiert echtes OpenAPI JSON
|
||||
openapi: {
|
||||
info: {
|
||||
title: "Multi-Tenant API",
|
||||
description: "API Dokumentation für dein Backend",
|
||||
version: "1.0.0",
|
||||
},
|
||||
servers: [{ url: "http://localhost:3000" }],
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
await server.register(swaggerUi, {
|
||||
routePrefix: "/docs", // UI erreichbar unter http://localhost:3000/docs
|
||||
swagger: {
|
||||
info: {
|
||||
title: "Multi-Tenant API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
exposeRoute: true,
|
||||
});
|
||||
});
|
||||
41
backend/src/plugins/tenant.ts
Normal file
41
backend/src/plugins/tenant.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
|
||||
export default fp(async (server: FastifyInstance) => {
|
||||
server.addHook("preHandler", async (req, reply) => {
|
||||
const host = req.headers.host?.split(":")[0]; // Domain ohne Port
|
||||
if (!host) {
|
||||
reply.code(400).send({ error: "Missing host header" });
|
||||
return;
|
||||
}
|
||||
// Tenant aus DB laden
|
||||
const { data: tenant } = await server.supabase
|
||||
.from("tenants")
|
||||
.select("*")
|
||||
.eq("portalDomain", host)
|
||||
.single();
|
||||
|
||||
|
||||
if(!tenant) {
|
||||
// Multi Tenant Mode
|
||||
(req as any).tenant = null;
|
||||
}else {
|
||||
// Tenant ins Request-Objekt hängen
|
||||
(req as any).tenant = tenant;
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
// Typ-Erweiterung
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
tenant?: {
|
||||
id: string;
|
||||
name: string;
|
||||
domain?: string;
|
||||
subdomain?: string;
|
||||
settings?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
}
|
||||
117
backend/src/routes/admin.ts
Normal file
117
backend/src/routes/admin.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import {
|
||||
authTenantUsers,
|
||||
authUsers,
|
||||
tenants,
|
||||
} from "../../db/schema";
|
||||
|
||||
export default async function adminRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/add-user-to-tenant
|
||||
// -------------------------------------------------------------
|
||||
server.post("/admin/add-user-to-tenant", async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as {
|
||||
user_id: string;
|
||||
tenant_id: number;
|
||||
role?: string;
|
||||
mode?: "single" | "multi";
|
||||
};
|
||||
|
||||
if (!body.user_id || !body.tenant_id) {
|
||||
return reply.code(400).send({
|
||||
error: "user_id and tenant_id required"
|
||||
});
|
||||
}
|
||||
|
||||
const mode = body.mode ?? "multi";
|
||||
|
||||
// ----------------------------
|
||||
// SINGLE MODE → alte Verknüpfungen löschen
|
||||
// ----------------------------
|
||||
if (mode === "single") {
|
||||
await server.db
|
||||
.delete(authTenantUsers)
|
||||
.where(eq(authTenantUsers.user_id, body.user_id));
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Neue Verknüpfung hinzufügen
|
||||
// ----------------------------
|
||||
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
// @ts-ignore
|
||||
.values({
|
||||
user_id: body.user_id,
|
||||
tenantId: body.tenant_id,
|
||||
role: body.role ?? "member",
|
||||
});
|
||||
|
||||
return { success: true, mode };
|
||||
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/add-user-to-tenant:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET /admin/user-tenants/:user_id
|
||||
// -------------------------------------------------------------
|
||||
server.get("/admin/user-tenants/:user_id", async (req, reply) => {
|
||||
try {
|
||||
const { user_id } = req.params as { user_id: string };
|
||||
|
||||
if (!user_id) {
|
||||
return reply.code(400).send({ error: "user_id required" });
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// 1) User existiert?
|
||||
// ----------------------------
|
||||
const [user] = await server.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, user_id))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(400).send({ error: "faulty user_id presented" });
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// 2) Tenants Join über auth_tenant_users
|
||||
// ----------------------------
|
||||
const tenantRecords = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
locked: tenants.locked,
|
||||
numberRanges: tenants.numberRanges,
|
||||
extraModules: tenants.extraModules,
|
||||
})
|
||||
.from(authTenantUsers)
|
||||
.innerJoin(
|
||||
tenants,
|
||||
eq(authTenantUsers.tenant_id, tenants.id)
|
||||
)
|
||||
.where(eq(authTenantUsers.user_id, user_id));
|
||||
|
||||
return {
|
||||
user_id,
|
||||
tenants: tenantRecords,
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/user-tenants:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
96
backend/src/routes/auth/auth-authenticated.ts
Normal file
96
backend/src/routes/auth/auth-authenticated.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import bcrypt from "bcrypt"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
|
||||
|
||||
export default async function authRoutesAuthenticated(server: FastifyInstance) {
|
||||
|
||||
server.post("/auth/password/change", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Change password (after login or forced reset)",
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["old_password", "new_password"],
|
||||
properties: {
|
||||
old_password: { type: "string" },
|
||||
new_password: { type: "string" },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
|
||||
try {
|
||||
const { old_password, new_password } = req.body as {
|
||||
old_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
const userId = req.user?.user_id
|
||||
if (!userId) {
|
||||
//@ts-ignore
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 1) User laden
|
||||
// -----------------------------------------------------
|
||||
const [user] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
passwordHash: authUsers.passwordHash,
|
||||
mustChangePassword: authUsers.must_change_password
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!user) {
|
||||
//@ts-ignore
|
||||
return reply.code(404).send({ error: "User not found" })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 2) Altes PW prüfen
|
||||
// -----------------------------------------------------
|
||||
const valid = await bcrypt.compare(old_password, user.passwordHash)
|
||||
if (!valid) {
|
||||
//@ts-ignore
|
||||
return reply.code(401).send({ error: "Old password incorrect" })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 3) Neues PW hashen
|
||||
// -----------------------------------------------------
|
||||
const newHash = await bcrypt.hash(new_password, 10)
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 4) Updaten
|
||||
// -----------------------------------------------------
|
||||
await server.db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
passwordHash: newHash,
|
||||
must_change_password: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(authUsers.id, userId))
|
||||
|
||||
return { success: true }
|
||||
|
||||
} catch (err) {
|
||||
console.error("POST /auth/password/change ERROR:", err)
|
||||
//@ts-ignore
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
224
backend/src/routes/auth/auth.ts
Normal file
224
backend/src/routes/auth/auth.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { generateRandomPassword, hashPassword } from "../../utils/password";
|
||||
import { sendMail } from "../../utils/mailer";
|
||||
import { secrets } from "../../utils/secrets";
|
||||
|
||||
import { authUsers } from "../../../db/schema";
|
||||
import { authTenantUsers } from "../../../db/schema";
|
||||
import { tenants } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export default async function authRoutes(server: FastifyInstance) {
|
||||
|
||||
// -----------------------------------------------------
|
||||
// REGISTER
|
||||
// -----------------------------------------------------
|
||||
server.post("/auth/register", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Register User",
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["email", "password"],
|
||||
properties: {
|
||||
email: { type: "string", format: "email" },
|
||||
password: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const body = req.body as { email: string; password: string };
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.password, 10);
|
||||
|
||||
const [user] = await server.db
|
||||
.insert(authUsers)
|
||||
.values({
|
||||
email: body.email.toLowerCase(),
|
||||
passwordHash,
|
||||
})
|
||||
.returning({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
});
|
||||
|
||||
return { user };
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------
|
||||
// LOGIN
|
||||
// -----------------------------------------------------
|
||||
server.post("/auth/login", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Login User",
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["email", "password"],
|
||||
properties: {
|
||||
email: { type: "string", format: "email" },
|
||||
password: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const body = req.body as { email: string; password: string };
|
||||
|
||||
let user: any = null;
|
||||
|
||||
// -------------------------------
|
||||
// SINGLE TENANT MODE
|
||||
// -------------------------------
|
||||
/* if (req.tenant) {
|
||||
const tenantId = req.tenant.id;
|
||||
|
||||
const result = await server.db
|
||||
.select({
|
||||
user: authUsers,
|
||||
})
|
||||
.from(authUsers)
|
||||
.innerJoin(
|
||||
authTenantUsers,
|
||||
eq(authTenantUsers.userId, authUsers.id)
|
||||
)
|
||||
.innerJoin(
|
||||
tenants,
|
||||
eq(authTenantUsers.tenantId, tenants.id)
|
||||
)
|
||||
.where(and(
|
||||
eq(authUsers.email, body.email.toLowerCase()),
|
||||
eq(authTenantUsers.tenantId, tenantId)
|
||||
));
|
||||
|
||||
if (result.length === 0) {
|
||||
return reply.code(401).send({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
user = result[0].user;
|
||||
|
||||
// -------------------------------
|
||||
// MULTI TENANT MODE
|
||||
// -------------------------------
|
||||
} else {*/
|
||||
const [found] = await server.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, body.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!found) {
|
||||
return reply.code(401).send({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
user = found;
|
||||
/*}*/
|
||||
|
||||
// Passwort prüfen
|
||||
const valid = await bcrypt.compare(body.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
tenant_id: req.tenant?.id ?? null,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: "6h" }
|
||||
);
|
||||
|
||||
reply.setCookie("token", token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 3,
|
||||
});
|
||||
|
||||
return { token };
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------
|
||||
// LOGOUT
|
||||
// -----------------------------------------------------
|
||||
server.post("/auth/logout", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Logout User"
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
reply.clearCookie("token", {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------
|
||||
// PASSWORD RESET
|
||||
// -----------------------------------------------------
|
||||
server.post("/auth/password/reset", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Reset Password",
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["email"],
|
||||
properties: {
|
||||
email: { type: "string", format: "email" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
const { email } = req.body as { email: string };
|
||||
|
||||
const [user] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
const plainPassword = generateRandomPassword();
|
||||
const passwordHash = await hashPassword(plainPassword);
|
||||
|
||||
|
||||
await server.db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
passwordHash,
|
||||
// @ts-ignore
|
||||
mustChangePassword: true,
|
||||
})
|
||||
.where(eq(authUsers.id, user.id));
|
||||
|
||||
await sendMail(
|
||||
user.email,
|
||||
"FEDEO | Dein neues Passwort",
|
||||
`
|
||||
<p>Hallo,</p>
|
||||
<p>Dein Passwort wurde zurückgesetzt.</p>
|
||||
<p><strong>Neues Passwort:</strong> ${plainPassword}</p>
|
||||
<p>Bitte ändere es nach dem Login umgehend.</p>
|
||||
`
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
140
backend/src/routes/auth/me.ts
Normal file
140
backend/src/routes/auth/me.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
authUsers,
|
||||
authTenantUsers,
|
||||
tenants,
|
||||
authProfiles,
|
||||
authUserRoles,
|
||||
authRoles,
|
||||
authRolePermissions,
|
||||
} from "../../../db/schema"
|
||||
import { eq, and, or, isNull } from "drizzle-orm"
|
||||
|
||||
export default async function meRoutes(server: FastifyInstance) {
|
||||
server.get("/me", async (req, reply) => {
|
||||
try {
|
||||
const authUser = req.user
|
||||
|
||||
if (!authUser) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const userId = authUser.user_id
|
||||
const activeTenantId = authUser.tenant_id
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 1) USER LADEN
|
||||
// ----------------------------------------------------
|
||||
const userResult = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
created_at: authUsers.created_at,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.limit(1)
|
||||
|
||||
const user = userResult[0]
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: "User not found" })
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 2) TENANTS LADEN
|
||||
// ----------------------------------------------------
|
||||
const tenantRows = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
locked: tenants.locked,
|
||||
extraModules: tenants.extraModules,
|
||||
businessInfo: tenants.businessInfo,
|
||||
numberRanges: tenants.numberRanges,
|
||||
dokuboxkey: tenants.dokuboxkey,
|
||||
standardEmailForInvoices: tenants.standardEmailForInvoices,
|
||||
standardPaymentDays: tenants.standardPaymentDays,
|
||||
})
|
||||
.from(authTenantUsers)
|
||||
.innerJoin(tenants, eq(authTenantUsers.tenant_id, tenants.id))
|
||||
.where(eq(authTenantUsers.user_id, userId))
|
||||
|
||||
const tenantList = tenantRows ?? []
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 3) ACTIVE TENANT
|
||||
// ----------------------------------------------------
|
||||
const activeTenant = activeTenantId
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 4) PROFIL LADEN
|
||||
// ----------------------------------------------------
|
||||
let profile = null
|
||||
if (activeTenantId) {
|
||||
const profileResult = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.user_id, userId),
|
||||
eq(authProfiles.tenant_id, activeTenantId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
profile = profileResult?.[0] ?? null
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 5) PERMISSIONS — RPC ERSETZT
|
||||
// ----------------------------------------------------
|
||||
const permissionRows =
|
||||
(await server.db
|
||||
.select({
|
||||
permission: authRolePermissions.permission,
|
||||
})
|
||||
.from(authUserRoles)
|
||||
.innerJoin(
|
||||
authRoles,
|
||||
and(
|
||||
eq(authRoles.id, authUserRoles.role_id),
|
||||
or(
|
||||
isNull(authRoles.tenant_id), // globale Rolle
|
||||
eq(authRoles.tenant_id, activeTenantId) // tenant-spezifische Rolle
|
||||
)
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
authRolePermissions,
|
||||
eq(authRolePermissions.role_id, authRoles.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(authUserRoles.user_id, userId),
|
||||
eq(authUserRoles.tenant_id, activeTenantId)
|
||||
)
|
||||
)) ?? []
|
||||
|
||||
const permissions = Array.from(
|
||||
new Set(permissionRows.map((p) => p.permission))
|
||||
)
|
||||
|
||||
// ----------------------------------------------------
|
||||
// RESPONSE
|
||||
// ----------------------------------------------------
|
||||
return {
|
||||
user,
|
||||
tenants: tenantList,
|
||||
activeTenant,
|
||||
profile,
|
||||
permissions,
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("ERROR in /me route:", err)
|
||||
return reply.code(500).send({ error: "Internal server error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
129
backend/src/routes/auth/user.ts
Normal file
129
backend/src/routes/auth/user.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
import {
|
||||
authUsers,
|
||||
authProfiles,
|
||||
} from "../../../db/schema"
|
||||
|
||||
export default async function userRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET /user/:id
|
||||
// -------------------------------------------------------------
|
||||
server.get("/user/:id", async (req, reply) => {
|
||||
try {
|
||||
const authUser = req.user
|
||||
const { id } = req.params as { id: string }
|
||||
|
||||
if (!authUser) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
// 1️⃣ User laden
|
||||
const [user] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
created_at: authUsers.created_at,
|
||||
must_change_password: authUsers.must_change_password,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, id))
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" })
|
||||
}
|
||||
|
||||
// 2️⃣ Profil im Tenant
|
||||
let profile = null
|
||||
|
||||
if (authUser.tenant_id) {
|
||||
const [profileRow] = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.user_id, id),
|
||||
eq(authProfiles.tenant_id, authUser.tenant_id)
|
||||
)
|
||||
)
|
||||
|
||||
profile = profileRow || null
|
||||
}
|
||||
|
||||
return { user, profile }
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("/user/:id ERROR", err)
|
||||
return reply.code(500).send({ error: err.message || "Internal error" })
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// PUT /user/:id/profile
|
||||
// -------------------------------------------------------------
|
||||
server.put("/user/:id/profile", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string }
|
||||
const { data } = req.body as { data?: Record<string, any> }
|
||||
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
return reply.code(400).send({ error: "data object required" })
|
||||
}
|
||||
|
||||
// 1️⃣ Profil für diesen Tenant laden (damit wir die ID kennen)
|
||||
const [profile] = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.user_id, id),
|
||||
eq(authProfiles.tenant_id, req.user.tenant_id)
|
||||
)
|
||||
)
|
||||
|
||||
if (!profile) {
|
||||
return reply.code(404).send({ error: "Profile not found in tenant" })
|
||||
}
|
||||
|
||||
// 2️⃣ Timestamp-Felder normalisieren (falls welche drin sind)
|
||||
const normalizeDate = (val: any) => {
|
||||
if (!val) return null
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const updateData: any = { ...data }
|
||||
|
||||
// bekannte Date-Felder prüfen
|
||||
if (data.entry_date !== undefined)
|
||||
updateData.entry_date = normalizeDate(data.entry_date)
|
||||
|
||||
if (data.birthday !== undefined)
|
||||
updateData.birthday = normalizeDate(data.birthday)
|
||||
|
||||
if (data.created_at !== undefined)
|
||||
updateData.created_at = normalizeDate(data.created_at)
|
||||
|
||||
updateData.updated_at = new Date()
|
||||
|
||||
// 3️⃣ Update durchführen
|
||||
const [updatedProfile] = await server.db
|
||||
.update(authProfiles)
|
||||
.set(updateData)
|
||||
.where(eq(authProfiles.id, profile.id))
|
||||
.returning()
|
||||
|
||||
return { profile: updatedProfile }
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("PUT /user/:id/profile ERROR", err)
|
||||
return reply.code(500).send({ error: err.message || "Internal server error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
236
backend/src/routes/banking.ts
Normal file
236
backend/src/routes/banking.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import axios from "axios"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { insertHistoryItem } from "../utils/history"
|
||||
|
||||
import {
|
||||
bankrequisitions,
|
||||
statementallocations,
|
||||
} from "../../db/schema"
|
||||
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
} from "drizzle-orm"
|
||||
|
||||
|
||||
export default async function bankingRoutes(server: FastifyInstance) {
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 🔐 GoCardLess Token Handling
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL
|
||||
const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID
|
||||
const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY
|
||||
|
||||
let tokenData: any = null
|
||||
|
||||
const getToken = async () => {
|
||||
const res = await axios.post(`${goCardLessBaseUrl}/token/new/`, {
|
||||
secret_id: goCardLessSecretId,
|
||||
secret_key: goCardLessSecretKey,
|
||||
})
|
||||
|
||||
tokenData = res.data
|
||||
tokenData.created_at = new Date().toISOString()
|
||||
|
||||
server.log.info("GoCardless token refreshed.")
|
||||
}
|
||||
|
||||
const checkToken = async () => {
|
||||
if (!tokenData) return await getToken()
|
||||
|
||||
const expired = dayjs(tokenData.created_at)
|
||||
.add(tokenData.access_expires, "seconds")
|
||||
.isBefore(dayjs())
|
||||
|
||||
if (expired) {
|
||||
server.log.info("Refreshing expired GoCardless token …")
|
||||
await getToken()
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 🔗 Create GoCardless Banking Link
|
||||
// ------------------------------------------------------------------
|
||||
server.get("/banking/link/:institutionid", async (req, reply) => {
|
||||
try {
|
||||
await checkToken()
|
||||
|
||||
const { institutionid } = req.params as { institutionid: string }
|
||||
const tenantId = req.user?.tenant_id
|
||||
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { data } = await axios.post(
|
||||
`${goCardLessBaseUrl}/requisitions/`,
|
||||
{
|
||||
redirect: "https://app.fedeo.de/settings/banking",
|
||||
institution_id: institutionid,
|
||||
user_language: "de",
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenData.access}` },
|
||||
}
|
||||
)
|
||||
|
||||
// DB: Requisition speichern
|
||||
await server.db.insert(bankrequisitions).values({
|
||||
id: data.id,
|
||||
tenant: tenantId,
|
||||
institutionId: institutionid,
|
||||
status: data.status,
|
||||
})
|
||||
|
||||
return reply.send({ link: data.link })
|
||||
} catch (err: any) {
|
||||
server.log.error(err?.response?.data || err)
|
||||
return reply.code(500).send({ error: "Failed to generate link" })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 🏦 Check Bank Institutions
|
||||
// ------------------------------------------------------------------
|
||||
server.get("/banking/institutions/:bic", async (req, reply) => {
|
||||
try {
|
||||
const { bic } = req.params as { bic: string }
|
||||
if (!bic) return reply.code(400).send("BIC missing")
|
||||
|
||||
await checkToken()
|
||||
|
||||
const { data } = await axios.get(
|
||||
`${goCardLessBaseUrl}/institutions/?country=de`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
)
|
||||
|
||||
const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase())
|
||||
|
||||
if (!bank) return reply.code(404).send("Bank not found")
|
||||
|
||||
return reply.send(bank)
|
||||
} catch (err: any) {
|
||||
server.log.error(err?.response?.data || err)
|
||||
return reply.code(500).send("Failed to fetch institutions")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 📄 Get Requisition Details
|
||||
// ------------------------------------------------------------------
|
||||
server.get("/banking/requisitions/:reqId", async (req, reply) => {
|
||||
try {
|
||||
const { reqId } = req.params as { reqId: string }
|
||||
if (!reqId) return reply.code(400).send("Requisition ID missing")
|
||||
|
||||
await checkToken()
|
||||
|
||||
const { data } = await axios.get(
|
||||
`${goCardLessBaseUrl}/requisitions/${reqId}`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
)
|
||||
|
||||
// Load account details
|
||||
if (data.accounts) {
|
||||
data.accounts = await Promise.all(
|
||||
data.accounts.map(async (accId: string) => {
|
||||
const { data: acc } = await axios.get(
|
||||
`${goCardLessBaseUrl}/accounts/${accId}`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||
)
|
||||
return acc
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return reply.send(data)
|
||||
} catch (err: any) {
|
||||
server.log.error(err?.response?.data || err)
|
||||
return reply.code(500).send("Failed to fetch requisition details")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 💰 Create Statement Allocation
|
||||
// ------------------------------------------------------------------
|
||||
server.post("/banking/statements", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { data: payload } = req.body as { data: any }
|
||||
|
||||
const inserted = await server.db.insert(statementallocations).values({
|
||||
...payload,
|
||||
tenant: req.user.tenant_id
|
||||
}).returning()
|
||||
|
||||
const createdRecord = inserted[0]
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: createdRecord.id,
|
||||
action: "created",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: null,
|
||||
newVal: createdRecord,
|
||||
text: "Buchung erstellt",
|
||||
})
|
||||
|
||||
return reply.send(createdRecord)
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Failed to create statement" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 🗑 Delete Statement Allocation
|
||||
// ------------------------------------------------------------------
|
||||
server.delete("/banking/statements/:id", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
|
||||
const oldRecord = await server.db
|
||||
.select()
|
||||
.from(statementallocations)
|
||||
.where(eq(statementallocations.id, id))
|
||||
.limit(1)
|
||||
|
||||
const old = oldRecord[0]
|
||||
|
||||
if (!old) return reply.code(404).send({ error: "Record not found" })
|
||||
|
||||
await server.db
|
||||
.delete(statementallocations)
|
||||
.where(eq(statementallocations.id, id))
|
||||
|
||||
await insertHistoryItem(server, {
|
||||
entity: "bankstatements",
|
||||
entityId: id,
|
||||
action: "deleted",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: old,
|
||||
newVal: null,
|
||||
text: "Buchung gelöscht",
|
||||
})
|
||||
|
||||
return reply.send({ success: true })
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Failed to delete statement" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
98
backend/src/routes/devices/rfid.ts
Normal file
98
backend/src/routes/devices/rfid.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {and, desc, eq} from "drizzle-orm";
|
||||
import {authProfiles, devices, stafftimeevents} from "../../../db/schema";
|
||||
|
||||
export default async function devicesRFIDRoutes(server: FastifyInstance) {
|
||||
server.post(
|
||||
"/rfid/createevent/:terminal_id",
|
||||
async (req, reply) => {
|
||||
try {
|
||||
|
||||
const {rfid_id} = req.body as {rfid_id: string};
|
||||
const {terminal_id} = req.params as {terminal_id: string};
|
||||
|
||||
if(!rfid_id ||!terminal_id) {
|
||||
console.log(`Missing Params`);
|
||||
return reply.code(400).send(`Missing Params`)
|
||||
}
|
||||
|
||||
const device = await server.db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(
|
||||
eq(devices.externalId, terminal_id)
|
||||
|
||||
)
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
if(!device) {
|
||||
console.log(`Device ${terminal_id} not found`);
|
||||
return reply.code(400).send(`Device ${terminal_id} not found`)
|
||||
|
||||
}
|
||||
|
||||
const profile = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.tenant_id, device.tenant),
|
||||
eq(authProfiles.token_id, rfid_id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
if(!profile) {
|
||||
console.log(`Profile for Token ${rfid_id} not found`);
|
||||
return reply.code(400).send(`Profile for Token ${rfid_id} not found`)
|
||||
|
||||
}
|
||||
|
||||
const lastEvent = await server.db
|
||||
.select()
|
||||
.from(stafftimeevents)
|
||||
.where(
|
||||
eq(stafftimeevents.user_id, profile.user_id)
|
||||
)
|
||||
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
console.log(lastEvent)
|
||||
|
||||
|
||||
const dataToInsert = {
|
||||
tenant_id: device.tenant,
|
||||
user_id: profile.user_id,
|
||||
actortype: "system",
|
||||
eventtime: new Date(),
|
||||
eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start",
|
||||
source: "WEB"
|
||||
}
|
||||
|
||||
console.log(dataToInsert)
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(dataToInsert)
|
||||
.returning()
|
||||
|
||||
return created
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
return reply.code(400).send({ error: err.message })
|
||||
}
|
||||
|
||||
|
||||
|
||||
console.log(req.body)
|
||||
|
||||
return
|
||||
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
262
backend/src/routes/emailAsUser.ts
Normal file
262
backend/src/routes/emailAsUser.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import nodemailer from "nodemailer"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { sendMailAsUser } from "../utils/emailengine"
|
||||
import { encrypt, decrypt } from "../utils/crypt"
|
||||
import { userCredentials } from "../../db/schema"
|
||||
// Pfad ggf. anpassen
|
||||
|
||||
// @ts-ignore
|
||||
import MailComposer from "nodemailer/lib/mail-composer/index.js"
|
||||
import { ImapFlow } from "imapflow"
|
||||
|
||||
export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// CREATE OR UPDATE EMAIL ACCOUNT
|
||||
// ======================================================================
|
||||
server.post("/email/accounts/:id?", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id?: string }
|
||||
|
||||
const body = req.body as {
|
||||
email: string
|
||||
password: string
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
smtp_ssl: boolean
|
||||
imap_host: string
|
||||
imap_port: number
|
||||
imap_ssl: boolean
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// UPDATE EXISTING
|
||||
// -----------------------------
|
||||
if (id) {
|
||||
const saveData = {
|
||||
emailEncrypted: body.email ? encrypt(body.email) : undefined,
|
||||
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
|
||||
smtpHostEncrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined,
|
||||
smtpPort: body.smtp_port,
|
||||
smtpSsl: body.smtp_ssl,
|
||||
imapHostEncrypted: body.imap_host ? encrypt(body.imap_host) : undefined,
|
||||
imapPort: body.imap_port,
|
||||
imapSsl: body.imap_ssl,
|
||||
}
|
||||
|
||||
await server.db
|
||||
.update(userCredentials)
|
||||
//@ts-ignore
|
||||
.set(saveData)
|
||||
.where(eq(userCredentials.id, id))
|
||||
|
||||
return reply.send({ success: true })
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// CREATE NEW
|
||||
// -----------------------------
|
||||
const insertData = {
|
||||
userId: req.user.user_id,
|
||||
tenantId: req.user.tenant_id,
|
||||
type: "mail",
|
||||
|
||||
emailEncrypted: encrypt(body.email),
|
||||
passwordEncrypted: encrypt(body.password),
|
||||
|
||||
smtpHostEncrypted: encrypt(body.smtp_host),
|
||||
smtpPort: body.smtp_port,
|
||||
smtpSsl: body.smtp_ssl,
|
||||
|
||||
imapHostEncrypted: encrypt(body.imap_host),
|
||||
imapPort: body.imap_port,
|
||||
imapSsl: body.imap_ssl,
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
await server.db.insert(userCredentials).values(insertData)
|
||||
|
||||
return reply.send({ success: true })
|
||||
} catch (err) {
|
||||
console.error("POST /email/accounts error:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// GET SINGLE OR ALL ACCOUNTS
|
||||
// ======================================================================
|
||||
server.get("/email/accounts/:id?", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id?: string }
|
||||
|
||||
// ============================================================
|
||||
// LOAD SINGLE ACCOUNT
|
||||
// ============================================================
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(userCredentials)
|
||||
.where(eq(userCredentials.id, id))
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
const returnData: any = {}
|
||||
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
if (key.endsWith("Encrypted")) {
|
||||
const cleanKey = key.replace("Encrypted", "")
|
||||
// @ts-ignore
|
||||
returnData[cleanKey] = decrypt(val as string)
|
||||
} else {
|
||||
returnData[key] = val
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send(returnData)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LOAD ALL ACCOUNTS FOR TENANT
|
||||
// ============================================================
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(userCredentials)
|
||||
.where(eq(userCredentials.tenantId, req.user.tenant_id))
|
||||
|
||||
const accounts = rows.map(row => {
|
||||
const temp: any = {}
|
||||
console.log(row)
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
console.log(key,val)
|
||||
if (key.endsWith("Encrypted") && val) {
|
||||
// @ts-ignore
|
||||
temp[key.replace("Encrypted", "")] = decrypt(val)
|
||||
} else {
|
||||
temp[key] = val
|
||||
}
|
||||
})
|
||||
return temp
|
||||
})
|
||||
|
||||
return reply.send(accounts)
|
||||
|
||||
} catch (err) {
|
||||
console.error("GET /email/accounts error:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// SEND EMAIL + SAVE IN IMAP SENT FOLDER
|
||||
// ======================================================================
|
||||
server.post("/email/send", async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as {
|
||||
to: string
|
||||
cc?: string
|
||||
bcc?: string
|
||||
subject?: string
|
||||
text?: string
|
||||
html?: string
|
||||
attachments?: any
|
||||
account: string
|
||||
}
|
||||
|
||||
// Fetch email credentials
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(userCredentials)
|
||||
.where(eq(userCredentials.id, body.account))
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return reply.code(404).send({ error: "Account not found" })
|
||||
|
||||
const accountData: any = {}
|
||||
|
||||
Object.entries(row).forEach(([key, val]) => {
|
||||
if (key.endsWith("Encrypted") && val) {
|
||||
// @ts-ignore
|
||||
accountData[key.replace("Encrypted", "")] = decrypt(val as string)
|
||||
} else {
|
||||
accountData[key] = val
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------
|
||||
// SEND EMAIL VIA SMTP
|
||||
// -------------------------
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: accountData.smtpHost,
|
||||
port: accountData.smtpPort,
|
||||
secure: accountData.smtpSsl,
|
||||
auth: {
|
||||
user: accountData.email,
|
||||
pass: accountData.password,
|
||||
},
|
||||
})
|
||||
|
||||
const message = {
|
||||
from: accountData.email,
|
||||
to: body.to,
|
||||
cc: body.cc,
|
||||
bcc: body.bcc,
|
||||
subject: body.subject,
|
||||
html: body.html,
|
||||
text: body.text,
|
||||
attachments: body.attachments,
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail(message)
|
||||
|
||||
// -------------------------
|
||||
// SAVE TO IMAP SENT FOLDER
|
||||
// -------------------------
|
||||
const imap = new ImapFlow({
|
||||
host: accountData.imapHost,
|
||||
port: accountData.imapPort,
|
||||
secure: accountData.imapSsl,
|
||||
auth: {
|
||||
user: accountData.email,
|
||||
pass: accountData.password,
|
||||
},
|
||||
})
|
||||
|
||||
await imap.connect()
|
||||
|
||||
const mail = new MailComposer(message)
|
||||
const raw = await mail.compile().build()
|
||||
|
||||
for await (const mailbox of await imap.list()) {
|
||||
if (mailbox.specialUse === "\\Sent") {
|
||||
await imap.mailboxOpen(mailbox.path)
|
||||
await imap.append(mailbox.path, raw, ["\\Seen"])
|
||||
await imap.logout()
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ success: true })
|
||||
|
||||
} catch (err) {
|
||||
console.error("POST /email/send error:", err)
|
||||
return reply.code(500).send({ error: "Failed to send email" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
128
backend/src/routes/exports.ts
Normal file
128
backend/src/routes/exports.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import {insertHistoryItem} from "../utils/history";
|
||||
import {buildExportZip} from "../utils/export/datev";
|
||||
import {s3} from "../utils/s3";
|
||||
import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3"
|
||||
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
|
||||
import dayjs from "dayjs";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {secrets} from "../utils/secrets";
|
||||
import {createSEPAExport} from "../utils/export/sepa";
|
||||
|
||||
const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
|
||||
console.log(startDate,endDate,beraternr,mandantennr)
|
||||
|
||||
// 1) ZIP erzeugen
|
||||
const buffer = await buildExportZip(server,req.user.tenant_id, startDate, endDate, beraternr, mandantennr)
|
||||
console.log("ZIP created")
|
||||
console.log(buffer)
|
||||
|
||||
// 2) Dateiname & Key festlegen
|
||||
const fileKey = `${req.user.tenant_id}/exports/Export_${dayjs(startDate).format("YYYY-MM-DD")}_${dayjs(endDate).format("YYYY-MM-DD")}_${randomUUID()}.zip`
|
||||
console.log(fileKey)
|
||||
|
||||
// 3) In S3 hochladen
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
Body: buffer,
|
||||
ContentType: "application/zip",
|
||||
})
|
||||
)
|
||||
|
||||
// 4) Presigned URL erzeugen (24h gültig)
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
}),
|
||||
{ expiresIn: 60 * 60 * 24 }
|
||||
)
|
||||
|
||||
console.log(url)
|
||||
|
||||
// 5) In Supabase-DB speichern
|
||||
const { data, error } = await server.supabase
|
||||
.from("exports")
|
||||
.insert([
|
||||
{
|
||||
tenant_id: req.user.tenant_id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
valid_until: dayjs().add(24,"hours").toISOString(),
|
||||
file_path: fileKey,
|
||||
url: url,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single()
|
||||
|
||||
console.log(data)
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
|
||||
export default async function exportRoutes(server: FastifyInstance) {
|
||||
//Export DATEV
|
||||
server.post("/exports/datev", async (req, reply) => {
|
||||
const { start_date, end_date, beraternr, mandantennr } = req.body as {
|
||||
start_date: string
|
||||
end_date: string
|
||||
beraternr: string
|
||||
mandantennr: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
reply.send({success:true})
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await createDatevExport(server,req,start_date,end_date,beraternr,mandantennr)
|
||||
console.log("Job done ✅")
|
||||
} catch (err) {
|
||||
console.error("Job failed ❌", err)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
server.post("/exports/sepa", async (req, reply) => {
|
||||
const { idsToExport } = req.body as {
|
||||
idsToExport: Array<number>
|
||||
}
|
||||
|
||||
|
||||
|
||||
reply.send({success:true})
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await createSEPAExport(server, idsToExport, req.user.tenant_id)
|
||||
console.log("Job done ✅")
|
||||
} catch (err) {
|
||||
console.error("Job failed ❌", err)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
//List Exports Available for Download
|
||||
|
||||
server.get("/exports", async (req,reply) => {
|
||||
const {data,error} = await server.supabase.from("exports").select().eq("tenant_id",req.user.tenant_id)
|
||||
|
||||
console.log(data,error)
|
||||
reply.send(data)
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
293
backend/src/routes/files.ts
Normal file
293
backend/src/routes/files.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import multipart from "@fastify/multipart"
|
||||
import { s3 } from "../utils/s3"
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand
|
||||
} from "@aws-sdk/client-s3"
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import archiver from "archiver"
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import {
|
||||
files,
|
||||
createddocuments,
|
||||
customers
|
||||
} from "../../db/schema"
|
||||
|
||||
|
||||
export default async function fileRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// MULTIPART INIT
|
||||
// -------------------------------------------------------------
|
||||
await server.register(multipart, {
|
||||
limits: { fileSize: 20 * 1024 * 1024 } // 20 MB
|
||||
})
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UPLOAD FILE
|
||||
// -------------------------------------------------------------
|
||||
server.post("/files/upload", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data: any = await req.file()
|
||||
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
|
||||
const fileBuffer = await data.toBuffer()
|
||||
|
||||
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
|
||||
|
||||
// 1️⃣ DB-Eintrag erzeugen
|
||||
const inserted = await server.db
|
||||
.insert(files)
|
||||
.values({ tenant: tenantId })
|
||||
.returning()
|
||||
|
||||
const created = inserted[0]
|
||||
if (!created) throw new Error("Could not create DB entry")
|
||||
|
||||
// 2️⃣ Datei in S3 speichern
|
||||
const fileKey = `${tenantId}/filesbyid/${created.id}/${data.filename}`
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: fileKey,
|
||||
Body: fileBuffer,
|
||||
ContentType: data.mimetype
|
||||
}))
|
||||
|
||||
// 3️⃣ DB updaten: meta + path
|
||||
await server.db
|
||||
.update(files)
|
||||
.set({
|
||||
...meta,
|
||||
path: fileKey
|
||||
})
|
||||
.where(eq(files.id, created.id))
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
filename: data.filename,
|
||||
path: fileKey
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Upload failed" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET FILE OR LIST FILES
|
||||
// -------------------------------------------------------------
|
||||
server.get("/files/:id?", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id?: string }
|
||||
|
||||
// 🔹 EINZELNE DATEI
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
// 🔹 ALLE DATEIEN DES TENANTS (mit createddocument + customer)
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const list = await server.db
|
||||
//@ts-ignore
|
||||
.select({
|
||||
...files,
|
||||
createddocument: createddocuments,
|
||||
customer: customers
|
||||
})
|
||||
.from(files)
|
||||
.leftJoin(
|
||||
createddocuments,
|
||||
eq(files.createddocument, createddocuments.id)
|
||||
)
|
||||
.leftJoin(
|
||||
customers,
|
||||
eq(createddocuments.customer, customers.id)
|
||||
)
|
||||
.where(eq(files.tenant, tenantId))
|
||||
|
||||
return { files: list }
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Could not load files" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// DOWNLOAD (SINGLE OR MULTI ZIP)
|
||||
// -------------------------------------------------------------
|
||||
server.post("/files/download/:id?", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id?: string }
|
||||
//@ts-ignore
|
||||
const ids = req.body?.ids || []
|
||||
|
||||
// -------------------------------------------------
|
||||
// 1️⃣ SINGLE DOWNLOAD
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
if (!file) return reply.code(404).send({ error: "File not found" })
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: file.path!
|
||||
})
|
||||
|
||||
const { Body, ContentType } = await s3.send(command)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of Body as any) chunks.push(chunk)
|
||||
const buffer = Buffer.concat(chunks)
|
||||
|
||||
reply.header("Content-Type", ContentType || "application/octet-stream")
|
||||
reply.header("Content-Disposition", `attachment; filename="${file.path?.split("/").pop()}"`)
|
||||
return reply.send(buffer)
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------
|
||||
// 2️⃣ MULTI DOWNLOAD → ZIP
|
||||
// -------------------------------------------------
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(inArray(files.id, ids))
|
||||
|
||||
if (!rows.length) return reply.code(404).send({ error: "Files not found" })
|
||||
|
||||
reply.header("Content-Type", "application/zip")
|
||||
reply.header("Content-Disposition", `attachment; filename="dateien.zip"`)
|
||||
|
||||
const archive = archiver("zip", { zlib: { level: 9 } })
|
||||
|
||||
for (const entry of rows) {
|
||||
const cmd = new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: entry.path!
|
||||
})
|
||||
const { Body } = await s3.send(cmd)
|
||||
|
||||
archive.append(Body as any, {
|
||||
name: entry.path?.split("/").pop() || entry.id
|
||||
})
|
||||
}
|
||||
|
||||
await archive.finalize()
|
||||
return reply.send(archive)
|
||||
}
|
||||
|
||||
return reply.code(400).send({ error: "No id or ids provided" })
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Download failed" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GENERATE PRESIGNED URL(S)
|
||||
// -------------------------------------------------------------
|
||||
server.post("/files/presigned/:id?", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id?: string }
|
||||
const { ids } = req.body as { ids?: string[] }
|
||||
const tenantId = req.user?.tenant_id
|
||||
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
// -------------------------------------------------
|
||||
// SINGLE FILE PRESIGNED URL
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
|
||||
{ expiresIn: 900 }
|
||||
)
|
||||
|
||||
return { ...file, url }
|
||||
} else {
|
||||
// -------------------------------------------------
|
||||
// MULTIPLE PRESIGNED URLs
|
||||
// -------------------------------------------------
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return reply.code(400).send({ error: "No ids provided" })
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.tenant, tenantId))
|
||||
|
||||
const selected = rows.filter(f => ids.includes(f.id) && f.path)
|
||||
|
||||
console.log(selected)
|
||||
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: selected[0].path! }),
|
||||
{ expiresIn: 900 }
|
||||
)
|
||||
console.log(url)
|
||||
console.log(selected.filter(f => !f.path))
|
||||
|
||||
const output = await Promise.all(
|
||||
selected.map(async (file) => {
|
||||
const url = await getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
|
||||
{ expiresIn: 900 }
|
||||
)
|
||||
return { ...file, url }
|
||||
})
|
||||
)
|
||||
|
||||
return { files: output }
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Could not create presigned URLs" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
222
backend/src/routes/functions.ts
Normal file
222
backend/src/routes/functions.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
||||
//import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||
import dayjs from "dayjs";
|
||||
//import { ready as zplReady } from 'zpl-renderer-js'
|
||||
//import { renderZPL } from "zpl-image";
|
||||
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat.js";
|
||||
import isoWeek from "dayjs/plugin/isoWeek.js";
|
||||
import isBetween from "dayjs/plugin/isBetween.js";
|
||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
|
||||
import duration from "dayjs/plugin/duration.js";
|
||||
import timezone from "dayjs/plugin/timezone.js";
|
||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||
import {citys} from "../../db/schema";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(isBetween)
|
||||
dayjs.extend(isSameOrAfter)
|
||||
dayjs.extend(isSameOrBefore)
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
export default async function functionRoutes(server: FastifyInstance) {
|
||||
server.post("/functions/pdf/:type", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
data: any
|
||||
backgroundPath?: string
|
||||
}
|
||||
const {type} = req.params as {type:string}
|
||||
|
||||
|
||||
try {
|
||||
|
||||
let pdf = null
|
||||
|
||||
if(type === "createdDocument") {
|
||||
pdf = await createInvoicePDF(
|
||||
server,
|
||||
"base64",
|
||||
body.data,
|
||||
body.backgroundPath
|
||||
)
|
||||
} else if(type === "timesheet") {
|
||||
pdf = await createTimeSheetPDF(
|
||||
server,
|
||||
"base64",
|
||||
body.data,
|
||||
body.backgroundPath
|
||||
)
|
||||
}
|
||||
|
||||
return pdf // Fastify wandelt automatisch in JSON
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
reply.code(500).send({ error: "Failed to create PDF" })
|
||||
}
|
||||
})
|
||||
|
||||
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
|
||||
const { numberrange } = req.params as { numberrange: string };
|
||||
const tenant = (req as any).user.tenant_id
|
||||
|
||||
try {
|
||||
const result = await useNextNumberRangeNumber(server,tenant, numberrange)
|
||||
reply.send(result) // JSON automatisch
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
reply.code(500).send({ error: "Failed to generate next number" })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @route GET /functions/workingtimeevaluation/:user_id
|
||||
* @query start_date=YYYY-MM-DD
|
||||
* @query end_date=YYYY-MM-DD
|
||||
*/
|
||||
server.get("/functions/timeevaluation/:user_id", async (req, reply) => {
|
||||
const { user_id } = req.params as { user_id: string }
|
||||
const { start_date, end_date } = req.query as { start_date: string; end_date: string }
|
||||
const { tenant_id } = req.user
|
||||
|
||||
// 🔒 Sicherheitscheck: andere User nur bei Berechtigung
|
||||
if (user_id !== req.user.user_id && !req.hasPermission("staff.time.read_all")) {
|
||||
return reply.code(403).send({ error: "Not allowed to view other users." })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateTimesEvaluation(server, user_id, tenant_id, start_date, end_date)
|
||||
reply.send(result)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
reply.code(500).send({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
server.get('/functions/check-zip/:zip', async (req, reply) => {
|
||||
const { zip } = req.params as { zip: string }
|
||||
|
||||
if (!zip) {
|
||||
return reply.code(400).send({ error: 'ZIP is required' })
|
||||
}
|
||||
|
||||
try {
|
||||
//@ts-ignore
|
||||
const data = await server.db.select().from(citys).where(eq(citys.zip,zip))
|
||||
|
||||
|
||||
/*const { data, error } = await server.supabase
|
||||
.from('citys')
|
||||
.select()
|
||||
.eq('zip', zip)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
console.log(error)
|
||||
return reply.code(500).send({ error: 'Database error' })
|
||||
}*/
|
||||
|
||||
if (!data) {
|
||||
return reply.code(404).send({ error: 'ZIP not found' })
|
||||
}
|
||||
|
||||
//districtMap
|
||||
const bundeslaender = [
|
||||
{ code: 'DE-BW', name: 'Baden-Württemberg' },
|
||||
{ code: 'DE-BY', name: 'Bayern' },
|
||||
{ code: 'DE-BE', name: 'Berlin' },
|
||||
{ code: 'DE-BB', name: 'Brandenburg' },
|
||||
{ code: 'DE-HB', name: 'Bremen' },
|
||||
{ code: 'DE-HH', name: 'Hamburg' },
|
||||
{ code: 'DE-HE', name: 'Hessen' },
|
||||
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
|
||||
{ code: 'DE-NI', name: 'Niedersachsen' },
|
||||
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
|
||||
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
|
||||
{ code: 'DE-SL', name: 'Saarland' },
|
||||
{ code: 'DE-SN', name: 'Sachsen' },
|
||||
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
|
||||
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
|
||||
{ code: 'DE-TH', name: 'Thüringen' }
|
||||
]
|
||||
|
||||
|
||||
|
||||
return reply.send({
|
||||
...data,
|
||||
//@ts-ignore
|
||||
state_code: bundeslaender.find(i => i.name === data.countryName)
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return reply.code(500).send({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
server.post('/functions/serial/start', async (req, reply) => {
|
||||
console.log(req.body)
|
||||
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
|
||||
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
|
||||
})
|
||||
|
||||
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
||||
const {execution_id} = req.params as { execution_id: string }
|
||||
//@ts-ignore
|
||||
await finishManualGeneration(server,execution_id)
|
||||
})
|
||||
|
||||
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
||||
await server.services.bankStatements.run(req.user.tenant_id);
|
||||
})
|
||||
|
||||
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
|
||||
|
||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
||||
})
|
||||
|
||||
|
||||
/*server.post('/print/zpl/preview', async (req, reply) => {
|
||||
const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}
|
||||
|
||||
console.log(widthMm,heightMm,dpmm)
|
||||
|
||||
if (!zpl) {
|
||||
return reply.code(400).send({ error: 'Missing ZPL string' })
|
||||
}
|
||||
|
||||
try {
|
||||
// 1️⃣ Renderer initialisieren
|
||||
const { api } = await zplReady
|
||||
|
||||
// 2️⃣ Rendern (liefert base64-encoded PNG)
|
||||
const base64Png = await api.zplToBase64Async(zpl, widthMm, heightMm, dpmm)
|
||||
|
||||
return await encodeBase64ToNiimbot(base64Png, 'top')
|
||||
} catch (err) {
|
||||
console.error('[ZPL Preview Error]', err)
|
||||
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
|
||||
}
|
||||
})
|
||||
|
||||
server.post('/print/label', async (req, reply) => {
|
||||
const { context, width=584, heigth=354 } = req.body as {context:any,width:number,heigth:number}
|
||||
|
||||
try {
|
||||
const base64 = await generateLabel(context,width,heigth)
|
||||
|
||||
return {
|
||||
encoded: await encodeBase64ToNiimbot(base64, 'top'),
|
||||
base64: base64
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ZPL Preview Error]', err)
|
||||
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
|
||||
}
|
||||
})*/
|
||||
|
||||
}
|
||||
14
backend/src/routes/health.ts
Normal file
14
backend/src/routes/health.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
export default async function routes(server: FastifyInstance) {
|
||||
server.get("/ping", async () => {
|
||||
// Testquery gegen DB
|
||||
const { data, error } = await server.supabase.from("tenants").select("id").limit(1);
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
db: error ? "not connected" : "connected",
|
||||
tenant_count: data?.length ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
104
backend/src/routes/helpdesk.inbound.email.ts
Normal file
104
backend/src/routes/helpdesk.inbound.email.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// modules/helpdesk/helpdesk.inbound.email.ts
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||
import {extractDomain, findCustomerOrContactByEmailOrDomain} from "../utils/helpers";
|
||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 📧 Interne M2M-Route für eingehende E-Mails
|
||||
// -------------------------------------------------------------
|
||||
|
||||
const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
|
||||
server.post('/helpdesk/inbound-email', async (req, res) => {
|
||||
|
||||
const {
|
||||
tenant_id,
|
||||
channel_id,
|
||||
from,
|
||||
subject,
|
||||
text,
|
||||
message_id,
|
||||
in_reply_to,
|
||||
} = req.body as {
|
||||
tenant_id: number
|
||||
channel_id: string
|
||||
from: {address: string, name: string}
|
||||
subject: string
|
||||
text: string
|
||||
message_id: string
|
||||
in_reply_to: string
|
||||
}
|
||||
|
||||
if (!tenant_id || !from?.address || !text) {
|
||||
return res.status(400).send({ error: 'Invalid payload' })
|
||||
}
|
||||
|
||||
server.log.info(`[InboundEmail] Neue Mail von ${from.address} für Tenant ${tenant_id}`)
|
||||
|
||||
// 1️⃣ Kunde & Kontakt ermitteln
|
||||
const { customer, contact: contactPerson } =
|
||||
(await findCustomerOrContactByEmailOrDomain(server, from.address, tenant_id)) || {}
|
||||
|
||||
// 2️⃣ Kontakt anlegen oder laden
|
||||
const contact = await getOrCreateContact(server, tenant_id, {
|
||||
email: from.address,
|
||||
display_name: from.name || from.address,
|
||||
customer_id: customer,
|
||||
contact_id: contactPerson,
|
||||
})
|
||||
|
||||
// 3️⃣ Konversation anhand In-Reply-To suchen
|
||||
let conversationId: string | null = null
|
||||
if (in_reply_to) {
|
||||
const { data: msg } = await server.supabase
|
||||
.from('helpdesk_messages')
|
||||
.select('conversation_id')
|
||||
.eq('external_message_id', in_reply_to)
|
||||
.maybeSingle()
|
||||
conversationId = msg?.conversation_id || null
|
||||
}
|
||||
|
||||
// 4️⃣ Neue Konversation anlegen falls keine existiert
|
||||
let conversation
|
||||
if (!conversationId) {
|
||||
conversation = await createConversation(server, {
|
||||
tenant_id,
|
||||
contact,
|
||||
channel_instance_id: channel_id,
|
||||
subject: subject || '(kein Betreff)',
|
||||
customer_id: customer,
|
||||
contact_person_id: contactPerson,
|
||||
})
|
||||
conversationId = conversation.id
|
||||
} else {
|
||||
const { data } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.select('*')
|
||||
.eq('id', conversationId)
|
||||
.single()
|
||||
conversation = data
|
||||
}
|
||||
|
||||
// 5️⃣ Nachricht speichern
|
||||
await addMessage(server, {
|
||||
tenant_id,
|
||||
conversation_id: conversationId,
|
||||
direction: 'incoming',
|
||||
payload: { type: 'text', text },
|
||||
external_message_id: message_id,
|
||||
raw_meta: { source: 'email' },
|
||||
})
|
||||
|
||||
server.log.info(`[InboundEmail] Ticket ${conversationId} gespeichert`)
|
||||
|
||||
return res.status(201).send({
|
||||
success: true,
|
||||
conversation_id: conversationId,
|
||||
ticket_number: conversation.ticket_number,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default helpdeskInboundEmailRoutes
|
||||
142
backend/src/routes/helpdesk.inbound.ts
Normal file
142
backend/src/routes/helpdesk.inbound.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// modules/helpdesk/helpdesk.inbound.routes.ts
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||
|
||||
/**
|
||||
* Öffentliche Route zum Empfang eingehender Kontaktformular-Nachrichten.
|
||||
* Authentifizierung: über `public_token` aus helpdesk_channel_instances
|
||||
*/
|
||||
|
||||
function extractDomain(email) {
|
||||
if (!email) return null
|
||||
const parts = email.split("@")
|
||||
return parts.length === 2 ? parts[1].toLowerCase() : null
|
||||
}
|
||||
|
||||
async function findCustomerOrContactByEmailOrDomain(server,fromMail, tenantId) {
|
||||
const sender = fromMail
|
||||
const senderDomain = extractDomain(sender)
|
||||
if (!senderDomain) return null
|
||||
|
||||
|
||||
// 1️⃣ Direkter Match über contacts
|
||||
const { data: contactMatch } = await server.supabase
|
||||
.from("contacts")
|
||||
.select("id, customer")
|
||||
.eq("email", sender)
|
||||
.eq("tenant", tenantId)
|
||||
.maybeSingle()
|
||||
|
||||
if (contactMatch?.customer_id) return {
|
||||
customer: contactMatch.customer,
|
||||
contact: contactMatch.id
|
||||
}
|
||||
|
||||
// 2️⃣ Kunden laden, bei denen E-Mail oder Rechnungsmail passt
|
||||
const { data: customers, error } = await server.supabase
|
||||
.from("customers")
|
||||
.select("id, infoData")
|
||||
.eq("tenant", tenantId)
|
||||
|
||||
if (error) {
|
||||
console.error(`[Helpdesk] Fehler beim Laden der Kunden:`, error.message)
|
||||
return null
|
||||
}
|
||||
|
||||
// 3️⃣ Durch Kunden iterieren und prüfen
|
||||
for (const c of customers || []) {
|
||||
const info = c.infoData || {}
|
||||
const email = info.email?.toLowerCase()
|
||||
const invoiceEmail = info.invoiceEmail?.toLowerCase()
|
||||
|
||||
const emailDomain = extractDomain(email)
|
||||
const invoiceDomain = extractDomain(invoiceEmail)
|
||||
|
||||
// exakter Match oder Domain-Match
|
||||
if (
|
||||
sender === email ||
|
||||
sender === invoiceEmail ||
|
||||
senderDomain === emailDomain ||
|
||||
senderDomain === invoiceDomain
|
||||
) {
|
||||
return {customer: c.id, contact:null}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
|
||||
// Öffentliche POST-Route
|
||||
server.post('/helpdesk/inbound/:public_token', async (req, res) => {
|
||||
const { public_token } = req.params as { public_token: string }
|
||||
const { email, phone, display_name, subject, message } = req.body as {
|
||||
email: string,
|
||||
phone: string,
|
||||
display_name: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).send({ error: 'Message content required' })
|
||||
}
|
||||
|
||||
// 1️⃣ Kanalinstanz anhand des Tokens ermitteln
|
||||
const { data: channel, error: channelError } = await server.supabase
|
||||
.from('helpdesk_channel_instances')
|
||||
.select('*')
|
||||
.eq('public_token', public_token)
|
||||
.single()
|
||||
|
||||
if (channelError || !channel) {
|
||||
return res.status(404).send({ error: 'Invalid channel token' })
|
||||
}
|
||||
|
||||
const tenant_id = channel.tenant_id
|
||||
const channel_instance_id = channel.id
|
||||
|
||||
// @ts-ignore
|
||||
const {customer, contact: contactPerson} = await findCustomerOrContactByEmailOrDomain(server,email, tenant_id )
|
||||
|
||||
|
||||
// 2️⃣ Kontakt finden oder anlegen
|
||||
const contact = await getOrCreateContact(server, tenant_id, {
|
||||
email,
|
||||
phone,
|
||||
display_name,
|
||||
customer_id: customer,
|
||||
contact_id: contactPerson,
|
||||
})
|
||||
|
||||
// 3️⃣ Konversation erstellen
|
||||
const conversation = await createConversation(server, {
|
||||
tenant_id,
|
||||
contact,
|
||||
channel_instance_id,
|
||||
subject: subject ?? 'Kontaktformular Anfrage',
|
||||
customer_id: customer,
|
||||
contact_person_id: contactPerson
|
||||
})
|
||||
|
||||
// 4️⃣ Erste Nachricht hinzufügen
|
||||
await addMessage(server, {
|
||||
tenant_id,
|
||||
conversation_id: conversation.id,
|
||||
direction: 'incoming',
|
||||
payload: { type: 'text', text: message },
|
||||
raw_meta: { source: 'contact_form' },
|
||||
})
|
||||
|
||||
// (optional) Auto-Antwort oder Event hier ergänzen
|
||||
|
||||
return res.status(201).send({
|
||||
success: true,
|
||||
conversation_id: conversation.id,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default helpdeskInboundRoutes
|
||||
331
backend/src/routes/helpdesk.ts
Normal file
331
backend/src/routes/helpdesk.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
// modules/helpdesk/helpdesk.routes.ts
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { createConversation, getConversations, updateConversationStatus } from '../modules/helpdesk/helpdesk.conversation.service.js'
|
||||
import { addMessage, getMessages } from '../modules/helpdesk/helpdesk.message.service.js'
|
||||
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
|
||||
import {decrypt, encrypt} from "../utils/crypt";
|
||||
import nodemailer from "nodemailer"
|
||||
|
||||
const helpdeskRoutes: FastifyPluginAsync = async (server) => {
|
||||
// 📩 1. Liste aller Konversationen
|
||||
server.get('/helpdesk/conversations', async (req, res) => {
|
||||
const tenant_id = req.user?.tenant_id
|
||||
if (!tenant_id) return res.status(401).send({ error: 'Unauthorized' })
|
||||
|
||||
const { status } = req.query as {status: string}
|
||||
const conversations = await getConversations(server, tenant_id, { status })
|
||||
return res.send(conversations)
|
||||
})
|
||||
|
||||
// 🆕 2. Neue Konversation erstellen
|
||||
server.post('/helpdesk/conversations', async (req, res) => {
|
||||
const tenant_id = req.user?.tenant_id
|
||||
if (!tenant_id) return res.status(401).send({ error: 'Unauthorized' })
|
||||
|
||||
const { contact, channel_instance_id, subject, message } = req.body as {
|
||||
contact: object
|
||||
channel_instance_id: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
if (!contact || !channel_instance_id) {
|
||||
return res.status(400).send({ error: 'Missing contact or channel_instance_id' })
|
||||
}
|
||||
|
||||
// 1. Konversation erstellen
|
||||
const conversation = await createConversation(server, {
|
||||
tenant_id,
|
||||
contact,
|
||||
channel_instance_id,
|
||||
subject,
|
||||
})
|
||||
|
||||
// 2. Falls erste Nachricht vorhanden → hinzufügen
|
||||
if (message) {
|
||||
await addMessage(server, {
|
||||
tenant_id,
|
||||
conversation_id: conversation.id,
|
||||
direction: 'incoming',
|
||||
payload: { type: 'text', text: message },
|
||||
})
|
||||
}
|
||||
|
||||
return res.status(201).send(conversation)
|
||||
})
|
||||
|
||||
// 🧭 3. Einzelne Konversation abrufen
|
||||
server.get('/helpdesk/conversations/:id', async (req, res) => {
|
||||
const tenant_id = req.user?.tenant_id
|
||||
const {id: conversation_id} = req.params as {id: string}
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from('helpdesk_conversations')
|
||||
.select('*, helpdesk_contacts(*)')
|
||||
.eq('tenant_id', tenant_id)
|
||||
.eq('id', conversation_id)
|
||||
.single()
|
||||
|
||||
if (error) return res.status(404).send({ error: 'Conversation not found' })
|
||||
return res.send(data)
|
||||
})
|
||||
|
||||
// 🔄 4. Konversation Status ändern
|
||||
server.patch('/helpdesk/conversations/:id/status', async (req, res) => {
|
||||
const {id: conversation_id} = req.params as { id: string }
|
||||
const { status } = req.body as { status: string }
|
||||
|
||||
const updated = await updateConversationStatus(server, conversation_id, status)
|
||||
return res.send(updated)
|
||||
})
|
||||
|
||||
// 💬 5. Nachrichten abrufen
|
||||
server.get('/helpdesk/conversations/:id/messages', async (req, res) => {
|
||||
const {id:conversation_id} = req.params as { id: string }
|
||||
const messages = await getMessages(server, conversation_id)
|
||||
return res.send(messages)
|
||||
})
|
||||
|
||||
// 💌 6. Nachricht hinzufügen (z. B. Antwort eines Agents)
|
||||
server.post('/helpdesk/conversations/:id/messages', async (req, res) => {
|
||||
console.log(req.user)
|
||||
const tenant_id = req.user?.tenant_id
|
||||
const author_user_id = req.user?.user_id
|
||||
const {id: conversation_id} = req.params as { id: string }
|
||||
const { text } = req.body as { text: string }
|
||||
|
||||
if (!text) return res.status(400).send({ error: 'Missing message text' })
|
||||
|
||||
const message = await addMessage(server, {
|
||||
tenant_id,
|
||||
conversation_id,
|
||||
author_user_id,
|
||||
direction: 'outgoing',
|
||||
payload: { type: 'text', text },
|
||||
})
|
||||
|
||||
return res.status(201).send(message)
|
||||
})
|
||||
|
||||
// 👤 7. Kontakt suchen oder anlegen
|
||||
server.post('/helpdesk/contacts', async (req, res) => {
|
||||
const tenant_id = req.user?.tenant_id
|
||||
const { email, phone, display_name } = req.body as { email: string; phone: string, display_name: string }
|
||||
|
||||
const contact = await getOrCreateContact(server, tenant_id, { email, phone, display_name })
|
||||
return res.status(201).send(contact)
|
||||
})
|
||||
|
||||
server.post("/helpdesk/channels", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["type_id", "name", "config"],
|
||||
properties: {
|
||||
type_id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
config: { type: "object" },
|
||||
is_active: { type: "boolean", default: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const { type_id, name, config, is_active = true } = req.body as
|
||||
{
|
||||
type_id: string,
|
||||
name: string,
|
||||
config: {
|
||||
imap:{
|
||||
host: string | object,
|
||||
user: string | object,
|
||||
pass: string | object,
|
||||
},
|
||||
smtp:{
|
||||
host: string | object,
|
||||
user: string | object,
|
||||
pass: string | object,
|
||||
}
|
||||
},
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
// 🔒 Tenant aus Auth-Context
|
||||
const tenant_id = req.user?.tenant_id
|
||||
if (!tenant_id) {
|
||||
return reply.status(401).send({ error: "Kein Tenant im Benutzerkontext gefunden." })
|
||||
}
|
||||
|
||||
if (type_id !== "email") {
|
||||
return reply.status(400).send({ error: "Nur Typ 'email' wird aktuell unterstützt." })
|
||||
}
|
||||
|
||||
try {
|
||||
const safeConfig = { ...config }
|
||||
|
||||
// 🔐 IMAP-Daten verschlüsseln
|
||||
if (safeConfig.imap) {
|
||||
if (safeConfig.imap.host)
|
||||
safeConfig.imap.host = encrypt(safeConfig.imap.host)
|
||||
if (safeConfig.imap.user)
|
||||
safeConfig.imap.user = encrypt(safeConfig.imap.user)
|
||||
if (safeConfig.imap.pass)
|
||||
safeConfig.imap.pass = encrypt(safeConfig.imap.pass)
|
||||
}
|
||||
|
||||
// 🔐 SMTP-Daten verschlüsseln
|
||||
if (safeConfig.smtp) {
|
||||
if (safeConfig.smtp.host)
|
||||
safeConfig.smtp.host = encrypt(safeConfig.smtp.host)
|
||||
if (safeConfig.smtp.user)
|
||||
safeConfig.smtp.user = encrypt(safeConfig.smtp.user)
|
||||
if (safeConfig.smtp.pass)
|
||||
safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass)
|
||||
}
|
||||
|
||||
// Speichern in Supabase
|
||||
const { data, error } = await server.supabase
|
||||
.from("helpdesk_channel_instances")
|
||||
.insert({
|
||||
tenant_id,
|
||||
type_id,
|
||||
name,
|
||||
config: safeConfig,
|
||||
is_active,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// sensible Felder aus Response entfernen
|
||||
if (data.config?.imap) {
|
||||
delete data.config.imap.host
|
||||
delete data.config.imap.user
|
||||
delete data.config.imap.pass
|
||||
}
|
||||
if (data.config?.smtp) {
|
||||
delete data.config.smtp.host
|
||||
delete data.config.smtp.user
|
||||
delete data.config.smtp.pass
|
||||
}
|
||||
|
||||
reply.send({
|
||||
message: "E-Mail-Channel erfolgreich erstellt",
|
||||
channel: data,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Fehler bei Channel-Erstellung:", err)
|
||||
reply.status(500).send({ error: err.message })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
server.post("/helpdesk/conversations/:id/reply", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const conversationId = (req.params as any).id
|
||||
const { text } = req.body as { text: string }
|
||||
|
||||
// 🔹 Konversation inkl. Channel + Kontakt laden
|
||||
const { data: conv, error: convErr } = await server.supabase
|
||||
.from("helpdesk_conversations")
|
||||
.select(`
|
||||
id,
|
||||
tenant_id,
|
||||
subject,
|
||||
channel_instance_id,
|
||||
helpdesk_contacts(email),
|
||||
helpdesk_channel_instances(config, name),
|
||||
ticket_number
|
||||
`)
|
||||
.eq("id", conversationId)
|
||||
.single()
|
||||
|
||||
console.log(conv)
|
||||
|
||||
if (convErr || !conv) {
|
||||
reply.status(404).send({ error: "Konversation nicht gefunden" })
|
||||
return
|
||||
}
|
||||
|
||||
const contact = conv.helpdesk_contacts as unknown as {email: string}
|
||||
const channel = conv.helpdesk_channel_instances as unknown as {name: string}
|
||||
|
||||
console.log(contact)
|
||||
if (!contact?.email) {
|
||||
reply.status(400).send({ error: "Kein Empfänger gefunden" })
|
||||
return
|
||||
}
|
||||
|
||||
// 🔐 SMTP-Daten entschlüsseln
|
||||
try {
|
||||
// @ts-ignore
|
||||
const smtp = channel?.config?.smtp
|
||||
const host =
|
||||
typeof smtp.host === "object" ? decrypt(smtp.host) : smtp.host
|
||||
const user =
|
||||
typeof smtp.user === "object" ? decrypt(smtp.user) : smtp.user
|
||||
const pass =
|
||||
typeof smtp.pass === "object" ? decrypt(smtp.pass) : smtp.pass
|
||||
|
||||
// 🔧 Transporter
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: smtp.port || 465,
|
||||
secure: smtp.secure ?? true,
|
||||
auth: { user, pass },
|
||||
})
|
||||
|
||||
// 📩 Mail senden
|
||||
|
||||
const mailOptions = {
|
||||
from: `"${channel?.name}" <${user}>`,
|
||||
to: contact.email,
|
||||
subject: `${conv.ticket_number} | ${conv.subject}` || `${conv.ticket_number} | Antwort vom FEDEO Helpdesk`,
|
||||
text,
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail(mailOptions)
|
||||
console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`)
|
||||
|
||||
// 💾 Nachricht speichern
|
||||
const { error: insertErr } = await server.supabase
|
||||
.from("helpdesk_messages")
|
||||
.insert({
|
||||
tenant_id: conv.tenant_id,
|
||||
conversation_id: conversationId,
|
||||
direction: "outgoing",
|
||||
payload: { type: "text", text },
|
||||
external_message_id: info.messageId,
|
||||
received_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (insertErr) throw insertErr
|
||||
|
||||
// 🔁 Konversation aktualisieren
|
||||
await server.supabase
|
||||
.from("helpdesk_conversations")
|
||||
.update({ last_message_at: new Date().toISOString() })
|
||||
.eq("id", conversationId)
|
||||
|
||||
reply.send({
|
||||
message: "E-Mail erfolgreich gesendet",
|
||||
messageId: info.messageId,
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error("Fehler beim SMTP-Versand:", err)
|
||||
reply.status(500).send({ error: err.message })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
export default helpdeskRoutes
|
||||
156
backend/src/routes/history.ts
Normal file
156
backend/src/routes/history.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// src/routes/resources/history.ts
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
const columnMap: Record<string, string> = {
|
||||
customers: "customer",
|
||||
vendors: "vendor",
|
||||
projects: "project",
|
||||
plants: "plant",
|
||||
contracts: "contract",
|
||||
contacts: "contact",
|
||||
tasks: "task",
|
||||
vehicles: "vehicle",
|
||||
events: "event",
|
||||
files: "file",
|
||||
products: "product",
|
||||
inventoryitems: "inventoryitem",
|
||||
inventoryitemgroups: "inventoryitemgroup",
|
||||
absencerequests: "absencerequest",
|
||||
checks: "check",
|
||||
costcentres: "costcentre",
|
||||
ownaccounts: "ownaccount",
|
||||
documentboxes: "documentbox",
|
||||
hourrates: "hourrate",
|
||||
services: "service",
|
||||
roles: "role",
|
||||
};
|
||||
|
||||
export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
||||
server.get<{
|
||||
Params: { resource: string; id: string }
|
||||
}>("/resource/:resource/:id/history", {
|
||||
schema: {
|
||||
tags: ["History"],
|
||||
summary: "Get history entries for a resource",
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["resource", "id"],
|
||||
properties: {
|
||||
resource: { type: "string" },
|
||||
id: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const { resource, id } = req.params;
|
||||
|
||||
const column = columnMap[resource];
|
||||
if (!column) {
|
||||
return reply.code(400).send({ error: `History not supported for resource '${resource}'` });
|
||||
}
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from("historyitems")
|
||||
.select("*")
|
||||
.eq(column, id)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
server.log.error(error);
|
||||
return reply.code(500).send({ error: "Failed to fetch history" });
|
||||
}
|
||||
|
||||
const {data:users, error:usersError} = await server.supabase
|
||||
.from("auth_users")
|
||||
.select("*, auth_profiles(*), tenants!auth_tenant_users(*)")
|
||||
|
||||
const filteredUsers = (users ||[]).filter(i => i.tenants.find((t:any) => t.id === req.user?.tenant_id))
|
||||
|
||||
const dataCombined = data.map(historyitem => {
|
||||
return {
|
||||
...historyitem,
|
||||
created_by_profile: filteredUsers.find(i => i.id === historyitem.created_by) ? filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] : null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
return dataCombined;
|
||||
});
|
||||
|
||||
// Neuen HistoryItem anlegen
|
||||
server.post<{
|
||||
Params: { resource: string; id: string };
|
||||
Body: {
|
||||
text: string;
|
||||
old_val?: string | null;
|
||||
new_val?: string | null;
|
||||
config?: Record<string, any>;
|
||||
};
|
||||
}>("/resource/:resource/:id/history", {
|
||||
schema: {
|
||||
tags: ["History"],
|
||||
summary: "Create new history entry",
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
resource: { type: "string" },
|
||||
id: { type: "string" }
|
||||
},
|
||||
required: ["resource", "id"]
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
old_val: { type: "string", nullable: true },
|
||||
new_val: { type: "string", nullable: true },
|
||||
config: { type: "object", nullable: true }
|
||||
},
|
||||
required: ["text"]
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number" },
|
||||
text: { type: "string" },
|
||||
created_at: { type: "string" },
|
||||
created_by: { type: "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
const { resource, id } = req.params;
|
||||
const { text, old_val, new_val, config } = req.body;
|
||||
|
||||
const userId = (req.user as any)?.user_id;
|
||||
|
||||
|
||||
const fkField = columnMap[resource];
|
||||
if (!fkField) {
|
||||
return reply.code(400).send({ error: `Unknown resource: ${resource}` });
|
||||
}
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from("historyitems")
|
||||
.insert({
|
||||
text,
|
||||
[fkField]: id,
|
||||
oldVal: old_val || null,
|
||||
newVal: new_val || null,
|
||||
config: config || null,
|
||||
tenant: (req.user as any)?.tenant_id,
|
||||
created_by: userId
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return reply.code(500).send({ error: error.message });
|
||||
}
|
||||
|
||||
return reply.code(201).send(data);
|
||||
});
|
||||
}
|
||||
41
backend/src/routes/internal/devices.ts
Normal file
41
backend/src/routes/internal/devices.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { devices } from "../../../db/schema";
|
||||
|
||||
export default async function deviceRoutes(fastify: FastifyInstance) {
|
||||
fastify.get<{
|
||||
Params: {
|
||||
externalId: string;
|
||||
};
|
||||
}>(
|
||||
"/devices/by-external-id/:externalId",
|
||||
async (request, reply) => {
|
||||
const { externalId } = request.params;
|
||||
|
||||
const device = await fastify.db
|
||||
.select({
|
||||
id: devices.id,
|
||||
name: devices.name,
|
||||
type: devices.type,
|
||||
tenant: devices.tenant,
|
||||
externalId: devices.externalId,
|
||||
created_at: devices.createdAt,
|
||||
})
|
||||
.from(devices)
|
||||
.where(
|
||||
eq(devices.externalId, externalId)
|
||||
|
||||
)
|
||||
.limit(1)
|
||||
.then(rows => rows[0]);
|
||||
|
||||
if (!device) {
|
||||
return reply.status(404).send({
|
||||
message: "Device not found",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send(device);
|
||||
}
|
||||
);
|
||||
}
|
||||
107
backend/src/routes/internal/tenant.ts
Normal file
107
backend/src/routes/internal/tenant.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
|
||||
import {
|
||||
authTenantUsers,
|
||||
authUsers,
|
||||
authProfiles,
|
||||
tenants
|
||||
} from "../../../db/schema"
|
||||
|
||||
import {and, eq, inArray} from "drizzle-orm"
|
||||
|
||||
|
||||
export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET CURRENT TENANT
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/:id", async (req) => {
|
||||
//@ts-ignore
|
||||
const tenant = (await server.db.select().from(tenants).where(eq(tenants.id,req.params.id)).limit(1))[0]
|
||||
|
||||
return tenant
|
||||
})
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT USERS (auth_users + auth_profiles)
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/users", async (req, reply) => {
|
||||
try {
|
||||
const authUser = req.user
|
||||
if (!authUser) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const tenantId = authUser.tenant_id
|
||||
|
||||
// 1) auth_tenant_users → user_ids
|
||||
const tenantUsers = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||||
|
||||
const userIds = tenantUsers.map(u => u.user_id)
|
||||
|
||||
if (!userIds.length) {
|
||||
return { tenant_id: tenantId, users: [] }
|
||||
}
|
||||
|
||||
// 2) auth_users laden
|
||||
const users = await server.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
|
||||
// 3) auth_profiles pro Tenant laden
|
||||
const profiles = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
|
||||
const combined = users.map(u => {
|
||||
const profile = profiles.find(p => p.user_id === u.id)
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
profile,
|
||||
full_name: profile?.full_name ?? null
|
||||
}
|
||||
})
|
||||
|
||||
return { tenant_id: tenantId, users: combined }
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/users ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT PROFILES
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/:id/profiles", async (req, reply) => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const tenantId = req.params.id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.tenant_id, tenantId))
|
||||
|
||||
return data
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/profiles ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
122
backend/src/routes/internal/time.ts
Normal file
122
backend/src/routes/internal/time.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
stafftimeentries,
|
||||
stafftimenetryconnects
|
||||
} from "../../../db/schema"
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
desc
|
||||
} from "drizzle-orm"
|
||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||
import {z} from "zod";
|
||||
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||
|
||||
export default async function staffTimeRoutesInternal(server: FastifyInstance) {
|
||||
|
||||
|
||||
server.post("/staff/time/event", async (req, reply) => {
|
||||
try {
|
||||
|
||||
const body = req.body as {user_id:string,tenant_id:number,eventtime:string,eventtype:string}
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
if (!val) return null
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const dataToInsert = {
|
||||
tenant_id: body.tenant_id,
|
||||
user_id: body.user_id,
|
||||
actortype: "user",
|
||||
actoruser_id: body.user_id,
|
||||
eventtime: normalizeDate(body.eventtime),
|
||||
eventtype: body.eventtype,
|
||||
source: "WEB"
|
||||
}
|
||||
|
||||
console.log(dataToInsert)
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(dataToInsert)
|
||||
.returning()
|
||||
|
||||
return created
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
return reply.code(400).send({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// GET /api/staff/time/spans
|
||||
server.get("/staff/time/spans", async (req, reply) => {
|
||||
try {
|
||||
|
||||
// Query-Parameter: targetUserId ist optional
|
||||
const { targetUserId, tenantId} = req.query as { targetUserId: string, tenantId:number };
|
||||
|
||||
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
|
||||
const evaluatedUserId = targetUserId;
|
||||
|
||||
// 💡 "Unendlicher" Zeitraum, wie gewünscht
|
||||
const startDate = new Date(0); // 1970
|
||||
const endDate = new Date("2100-12-31");
|
||||
|
||||
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
|
||||
const allEventsInTimeFrame = await loadValidEvents(
|
||||
server,
|
||||
tenantId,
|
||||
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// SCHRITT 2: Filtere faktische Events
|
||||
const FACTUAL_EVENT_TYPES = new Set([
|
||||
"work_start", "work_end", "pause_start", "pause_end",
|
||||
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
||||
"overtime_compensation_start", "overtime_compensation_end",
|
||||
"auto_stop"
|
||||
]);
|
||||
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
||||
|
||||
// SCHRITT 3: Hole administrative Events
|
||||
const factualEventIds = factualEvents.map(e => e.id);
|
||||
|
||||
if (factualEventIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
||||
|
||||
// SCHRITT 4: Kombinieren und Sortieren
|
||||
const combinedEvents = [
|
||||
...factualEvents,
|
||||
...relatedAdminEvents,
|
||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||
|
||||
// SCHRITT 5: Spans ableiten
|
||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||
|
||||
// SCHRITT 6: Spans anreichern
|
||||
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
||||
|
||||
return enrichedSpans;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden der Spans:", error);
|
||||
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
30
backend/src/routes/notifications.ts
Normal file
30
backend/src/routes/notifications.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// routes/notifications.routes.ts
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { NotificationService, UserDirectory } from '../modules/notification.service';
|
||||
|
||||
// Beispiel: E-Mail aus eigener User-Tabelle laden
|
||||
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
|
||||
const { data, error } = await server.supabase
|
||||
.from('auth_users')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
if (error || !data) return null;
|
||||
return { email: data.email };
|
||||
};
|
||||
|
||||
export default async function notificationsRoutes(server: FastifyInstance) {
|
||||
// wichtig: server.supabase ist über app verfügbar
|
||||
|
||||
const svc = new NotificationService(server, getUserDirectory);
|
||||
|
||||
server.post('/notifications/trigger', async (req, reply) => {
|
||||
try {
|
||||
const res = await svc.trigger(req.body as any);
|
||||
reply.send(res);
|
||||
} catch (err: any) {
|
||||
server.log.error(err);
|
||||
reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
120
backend/src/routes/profiles.ts
Normal file
120
backend/src/routes/profiles.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
import {
|
||||
authProfiles,
|
||||
} from "../../db/schema";
|
||||
|
||||
export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET SINGLE PROFILE
|
||||
// -------------------------------------------------------------
|
||||
server.get("/profiles/:id", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string };
|
||||
const tenantId = (req.user as any)?.tenant_id;
|
||||
|
||||
if (!tenantId) {
|
||||
return reply.code(400).send({ error: "No tenant selected" });
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.id, id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows.length) {
|
||||
return reply.code(404).send({ error: "User not found or not in tenant" });
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
|
||||
} catch (error) {
|
||||
console.error("GET /profiles/:id ERROR:", error);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
function sanitizeProfileUpdate(body: any) {
|
||||
const cleaned: any = { ...body }
|
||||
|
||||
// ❌ Systemfelder entfernen
|
||||
const forbidden = [
|
||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||
"updatedAt", "updatedBy", "old_profile_id", "full_name"
|
||||
]
|
||||
forbidden.forEach(f => delete cleaned[f])
|
||||
|
||||
// ❌ Falls NULL Strings vorkommen → in null umwandeln
|
||||
for (const key of Object.keys(cleaned)) {
|
||||
if (cleaned[key] === "") cleaned[key] = null
|
||||
}
|
||||
|
||||
// ✅ Date-Felder sauber konvertieren, falls vorhanden
|
||||
const dateFields = ["birthday", "entry_date"]
|
||||
|
||||
for (const field of dateFields) {
|
||||
if (cleaned[field]) {
|
||||
const d = new Date(cleaned[field])
|
||||
if (!isNaN(d.getTime())) cleaned[field] = d
|
||||
else delete cleaned[field] // invalid → entfernen
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UPDATE PROFILE
|
||||
// -------------------------------------------------------------
|
||||
server.put("/profiles/:id", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
|
||||
if (!tenantId) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
let body = req.body as any
|
||||
|
||||
// Clean + Normalize
|
||||
body = sanitizeProfileUpdate(body)
|
||||
|
||||
const updateData = {
|
||||
...body,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
}
|
||||
|
||||
const updated = await server.db
|
||||
.update(authProfiles)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.id, id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated.length) {
|
||||
return reply.code(404).send({ error: "User not found or not in tenant" })
|
||||
}
|
||||
|
||||
return updated[0]
|
||||
|
||||
} catch (err) {
|
||||
console.error("PUT /profiles/:id ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
41
backend/src/routes/publiclinks/publiclinks-authenticated.ts
Normal file
41
backend/src/routes/publiclinks/publiclinks-authenticated.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||
|
||||
|
||||
export default async function publiclinksAuthenticatedRoutes(server: FastifyInstance) {
|
||||
server.post("/publiclinks", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = 21; // Hardcoded für Test, später: req.user.tenantId
|
||||
|
||||
const { name, isProtected, pin, customToken, config, defaultProfileId } = req.body as { name:string, isProtected:boolean, pin:string, customToken:string, config:Object, defaultProfileId:string};
|
||||
|
||||
const newLink = await publicLinkService.createLink(server, tenantId,
|
||||
name,
|
||||
isProtected,
|
||||
pin,
|
||||
customToken,
|
||||
config,
|
||||
defaultProfileId);
|
||||
|
||||
return reply.code(201).send({
|
||||
success: true,
|
||||
data: {
|
||||
id: newLink.id,
|
||||
token: newLink.token,
|
||||
fullUrl: `/public/${newLink.token}`, // Helper für Frontend
|
||||
isProtected: newLink.isProtected
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
server.log.error(error);
|
||||
|
||||
// Einfache Fehlerbehandlung
|
||||
if (error.message.includes("bereits vergeben")) {
|
||||
return reply.code(409).send({ error: error.message });
|
||||
}
|
||||
|
||||
return reply.code(500).send({ error: "Fehler beim Erstellen des Links", details: error.message });
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||
|
||||
|
||||
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
||||
server.get("/workflows/context/:token", async (req, reply) => {
|
||||
const { token } = req.params as { token: string };
|
||||
|
||||
// Wir lesen die PIN aus dem Header (Best Practice für Security)
|
||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||
|
||||
try {
|
||||
const context = await publicLinkService.getLinkContext(server, token, pin);
|
||||
|
||||
return reply.send(context);
|
||||
|
||||
} catch (error: any) {
|
||||
// Spezifische Fehlercodes für das Frontend
|
||||
if (error.message === "Link_NotFound") {
|
||||
return reply.code(404).send({ error: "Link nicht gefunden oder abgelaufen" });
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Required") {
|
||||
return reply.code(401).send({
|
||||
error: "PIN erforderlich",
|
||||
code: "PIN_REQUIRED",
|
||||
requirePin: true
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Invalid") {
|
||||
return reply.code(403).send({
|
||||
error: "PIN falsch",
|
||||
code: "PIN_INVALID",
|
||||
requirePin: true
|
||||
});
|
||||
}
|
||||
|
||||
server.log.error(error);
|
||||
return reply.code(500).send({ error: "Interner Server Fehler" });
|
||||
}
|
||||
});
|
||||
|
||||
server.post("/workflows/submit/:token", async (req, reply) => {
|
||||
const { token } = req.params as { token: string };
|
||||
// PIN sicher aus dem Header lesen
|
||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||
// Der Body enthält { profile, project, service, ... }
|
||||
const payload = req.body;
|
||||
|
||||
console.log(payload)
|
||||
|
||||
try {
|
||||
// Service aufrufen (führt die 3 Schritte aus: Lieferschein -> Zeit -> History)
|
||||
const result = await publicLinkService.submitFormData(server, token, payload, pin);
|
||||
|
||||
// 201 Created zurückgeben
|
||||
return reply.code(201).send(result);
|
||||
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
|
||||
// Fehler-Mapping für saubere HTTP Codes
|
||||
if (error.message === "Link_NotFound") {
|
||||
return reply.code(404).send({ error: "Link ungültig oder nicht aktiv" });
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Required") {
|
||||
return reply.code(401).send({ error: "PIN erforderlich" });
|
||||
}
|
||||
|
||||
if (error.message === "Pin_Invalid") {
|
||||
return reply.code(403).send({ error: "PIN ist falsch" });
|
||||
}
|
||||
|
||||
if (error.message === "Profile_Missing") {
|
||||
return reply.code(400).send({ error: "Kein Mitarbeiter-Profil gefunden (weder im Link noch in der Eingabe)" });
|
||||
}
|
||||
|
||||
if (error.message === "Project not found" || error.message === "Service not found") {
|
||||
return reply.code(400).send({ error: "Ausgewähltes Projekt oder Leistung existiert nicht mehr." });
|
||||
}
|
||||
|
||||
// Fallback für alle anderen Fehler (z.B. DB Constraints)
|
||||
return reply.code(500).send({
|
||||
error: "Interner Fehler beim Speichern",
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
555
backend/src/routes/resources/main.ts
Normal file
555
backend/src/routes/resources/main.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
eq,
|
||||
ilike,
|
||||
asc,
|
||||
desc,
|
||||
and,
|
||||
count,
|
||||
inArray,
|
||||
or
|
||||
} from "drizzle-orm"
|
||||
|
||||
|
||||
|
||||
import {resourceConfig} from "../../utils/resource.config";
|
||||
import {useNextNumberRangeNumber} from "../../utils/functions";
|
||||
import {stafftimeentries} from "../../../db/schema";
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// SQL Volltextsuche auf mehreren Feldern
|
||||
// -------------------------------------------------------------
|
||||
|
||||
|
||||
function buildSearchCondition(table: any, columns: string[], search: string) {
|
||||
if (!search || !columns.length) return null
|
||||
|
||||
const term = `%${search.toLowerCase()}%`
|
||||
|
||||
const conditions = columns
|
||||
.map((colName) => table[colName])
|
||||
.filter(Boolean)
|
||||
.map((col) => ilike(col, term))
|
||||
|
||||
if (conditions.length === 0) return null
|
||||
|
||||
// @ts-ignore
|
||||
return or(...conditions)
|
||||
}
|
||||
|
||||
export default async function resourceRoutes(server: FastifyInstance) {
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// LIST
|
||||
// -------------------------------------------------------------
|
||||
server.get("/resource/:resource", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId)
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
|
||||
const { search, sort, asc: ascQuery } = req.query as {
|
||||
search?: string
|
||||
sort?: string
|
||||
asc?: string
|
||||
}
|
||||
|
||||
const {resource} = req.params as {resource: string}
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
// WHERE-Basis
|
||||
let whereCond: any = eq(table.tenant, tenantId)
|
||||
|
||||
// 🔍 SQL Search
|
||||
if(search) {
|
||||
const searchCond = buildSearchCondition(
|
||||
table,
|
||||
resourceConfig[resource].searchColumns,
|
||||
search.trim()
|
||||
)
|
||||
|
||||
if (searchCond) {
|
||||
whereCond = and(whereCond, searchCond)
|
||||
}
|
||||
}
|
||||
|
||||
// Base Query
|
||||
let q = server.db.select().from(table).where(whereCond)
|
||||
|
||||
// Sortierung
|
||||
if (sort) {
|
||||
const col = (table as any)[sort]
|
||||
if (col) {
|
||||
//@ts-ignore
|
||||
q = ascQuery === "true"
|
||||
? q.orderBy(asc(col))
|
||||
: q.orderBy(desc(col))
|
||||
}
|
||||
}
|
||||
|
||||
const queryData = await q
|
||||
|
||||
// RELATION LOADING (MANY-TO-ONE)
|
||||
|
||||
let ids = {}
|
||||
let lists = {}
|
||||
let maps = {}
|
||||
let data = [...queryData]
|
||||
|
||||
if(resourceConfig[resource].mtoLoad) {
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
ids[relation] = [...new Set(queryData.map(r => r[relation]).filter(Boolean))];
|
||||
})
|
||||
|
||||
for await (const relation of resourceConfig[resource].mtoLoad ) {
|
||||
console.log(relation)
|
||||
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
|
||||
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
|
||||
})
|
||||
|
||||
data = queryData.map(row => {
|
||||
let toReturn = {
|
||||
...row
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
|
||||
})
|
||||
|
||||
return toReturn
|
||||
});
|
||||
}
|
||||
|
||||
if(resourceConfig[resource].mtmListLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtmListLoad) {
|
||||
console.log(relation)
|
||||
console.log(resource.substring(0,resource.length-1))
|
||||
|
||||
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
|
||||
|
||||
console.log(relationRows.length)
|
||||
|
||||
data = data.map(row => {
|
||||
let toReturn = {
|
||||
...row
|
||||
}
|
||||
|
||||
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
|
||||
|
||||
return toReturn
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
} catch (err) {
|
||||
console.error("ERROR /resource/:resource", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// PAGINATED LIST
|
||||
// -------------------------------------------------------------
|
||||
server.get("/resource/:resource/paginated", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id;
|
||||
if (!tenantId) {
|
||||
return reply.code(400).send({ error: "No tenant selected" });
|
||||
}
|
||||
|
||||
const {resource} = req.params as {resource: string};
|
||||
|
||||
const {queryConfig} = req;
|
||||
const {
|
||||
pagination,
|
||||
sort,
|
||||
filters,
|
||||
paginationDisabled
|
||||
} = queryConfig;
|
||||
|
||||
const { search, distinctColumns } = req.query as {
|
||||
search?: string;
|
||||
distinctColumns?: string;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
let table = resourceConfig[resource].table
|
||||
|
||||
|
||||
let whereCond: any = eq(table.tenant, tenantId);
|
||||
|
||||
|
||||
if(search) {
|
||||
const searchCond = buildSearchCondition(
|
||||
table,
|
||||
resourceConfig[resource].searchColumns,
|
||||
search.trim()
|
||||
)
|
||||
|
||||
if (searchCond) {
|
||||
whereCond = and(whereCond, searchCond)
|
||||
}
|
||||
}
|
||||
|
||||
if (filters) {
|
||||
for (const [key, val] of Object.entries(filters)) {
|
||||
const col = (table as any)[key];
|
||||
if (!col) continue;
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
whereCond = and(whereCond, inArray(col, val));
|
||||
} else {
|
||||
whereCond = and(whereCond, eq(col, val as any));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------
|
||||
// COUNT (for pagination)
|
||||
// -----------------------------------------------
|
||||
const totalRes = await server.db
|
||||
.select({ value: count(table.id) })
|
||||
.from(table)
|
||||
.where(whereCond);
|
||||
|
||||
const total = Number(totalRes[0]?.value ?? 0);
|
||||
|
||||
// -----------------------------------------------
|
||||
// DISTINCT VALUES (regardless of pagination)
|
||||
// -----------------------------------------------
|
||||
const distinctValues: Record<string, any[]> = {};
|
||||
|
||||
if (distinctColumns) {
|
||||
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
|
||||
const col = (table as any)[colName];
|
||||
if (!col) continue;
|
||||
|
||||
const rows = await server.db
|
||||
.select({ v: col })
|
||||
.from(table)
|
||||
.where(eq(table.tenant, tenantId));
|
||||
|
||||
const values = rows
|
||||
.map(r => r.v)
|
||||
.filter(v => v != null && v !== "");
|
||||
|
||||
distinctValues[colName] = [...new Set(values)].sort();
|
||||
}
|
||||
}
|
||||
|
||||
// PAGINATION
|
||||
const offset = pagination?.offset ?? 0;
|
||||
const limit = pagination?.limit ?? 100;
|
||||
|
||||
// SORTING
|
||||
let orderField: any = null;
|
||||
let direction: "asc" | "desc" = "asc";
|
||||
|
||||
if (sort?.length > 0) {
|
||||
const s = sort[0];
|
||||
const col = (table as any)[s.field];
|
||||
if (col) {
|
||||
orderField = col;
|
||||
direction = s.direction === "asc" ? "asc" : "desc";
|
||||
}
|
||||
}
|
||||
|
||||
// MAIN QUERY (Paginated)
|
||||
let q = server.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(whereCond)
|
||||
.offset(offset)
|
||||
.limit(limit);
|
||||
|
||||
if (orderField) {
|
||||
//@ts-ignore
|
||||
q = direction === "asc"
|
||||
? q.orderBy(asc(orderField))
|
||||
: q.orderBy(desc(orderField));
|
||||
}
|
||||
|
||||
const rows = await q;
|
||||
|
||||
if (!rows.length) {
|
||||
return {
|
||||
data: [],
|
||||
queryConfig: {
|
||||
...queryConfig,
|
||||
total,
|
||||
totalPages: 0,
|
||||
distinctValues
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
let data = [...rows]
|
||||
//Many to One
|
||||
if(resourceConfig[resource].mtoLoad) {
|
||||
let ids = {}
|
||||
let lists = {}
|
||||
let maps = {}
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
ids[relation] = [...new Set(rows.map(r => r[relation]).filter(Boolean))];
|
||||
})
|
||||
|
||||
for await (const relation of resourceConfig[resource].mtoLoad ) {
|
||||
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
|
||||
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
|
||||
})
|
||||
|
||||
data = rows.map(row => {
|
||||
let toReturn = {
|
||||
...row
|
||||
}
|
||||
|
||||
resourceConfig[resource].mtoLoad.forEach(relation => {
|
||||
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
|
||||
})
|
||||
|
||||
return toReturn
|
||||
});
|
||||
}
|
||||
|
||||
if(resourceConfig[resource].mtmListLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtmListLoad) {
|
||||
console.log(relation)
|
||||
|
||||
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
|
||||
|
||||
console.log(relationRows)
|
||||
|
||||
data = data.map(row => {
|
||||
let toReturn = {
|
||||
...row
|
||||
}
|
||||
|
||||
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
|
||||
|
||||
return toReturn
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------
|
||||
// RETURN DATA
|
||||
// -----------------------------------------------
|
||||
return {
|
||||
data,
|
||||
queryConfig: {
|
||||
...queryConfig,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
distinctValues
|
||||
}
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error(`ERROR /resource/:resource/paginated:`, err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// DETAIL (mit JOINS)
|
||||
// -------------------------------------------------------------
|
||||
server.get("/resource/:resource/:id/:no_relations?", async (req, reply) => {
|
||||
try {
|
||||
const { id } = req.params as { id: string }
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
||||
|
||||
const {resource, no_relations} = req.params as { resource: string, no_relations?: boolean }
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
const projRows = await server.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
|
||||
.limit(1)
|
||||
|
||||
if (!projRows.length)
|
||||
return reply.code(404).send({ error: "Resource not found" })
|
||||
|
||||
// ------------------------------------
|
||||
// LOAD RELATIONS
|
||||
// ------------------------------------
|
||||
|
||||
let ids = {}
|
||||
let lists = {}
|
||||
let maps = {}
|
||||
let data = {
|
||||
...projRows[0]
|
||||
}
|
||||
|
||||
if(!no_relations) {
|
||||
if(resourceConfig[resource].mtoLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtoLoad ) {
|
||||
if(data[relation]) {
|
||||
data[relation] = (await server.db.select().from(resourceConfig[relation + "s"].table).where(eq(resourceConfig[relation + "s"].table.id, data[relation])))[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(resourceConfig[resource].mtmLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtmLoad ) {
|
||||
console.log(relation)
|
||||
data[relation] = await server.db.select().from(resourceConfig[relation].table).where(eq(resourceConfig[relation].table[resource.substring(0,resource.length - 1)],id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return data
|
||||
|
||||
} catch (err) {
|
||||
console.error("ERROR /resource/projects/:id", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
// Create
|
||||
server.post("/resource/:resource", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({error: "No tenant selected"});
|
||||
}
|
||||
|
||||
const {resource} = req.params as { resource: string };
|
||||
const body = req.body as Record<string, any>;
|
||||
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
let createData = {
|
||||
...body,
|
||||
tenant: req.user.tenant_id,
|
||||
archived: false, // Standardwert
|
||||
}
|
||||
|
||||
console.log(resourceConfig[resource].numberRangeHolder)
|
||||
|
||||
if (resourceConfig[resource].numberRangeHolder && !body[resourceConfig[resource]]) {
|
||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource)
|
||||
console.log(result)
|
||||
createData[resourceConfig[resource].numberRangeHolder] = result.usedNumber
|
||||
}
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
Object.keys(createData).forEach((key) => {
|
||||
if(key.toLowerCase().includes("date")) createData[key] = normalizeDate(createData[key])
|
||||
})
|
||||
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(table)
|
||||
.values(createData)
|
||||
.returning()
|
||||
|
||||
|
||||
/*await insertHistoryItem(server, {
|
||||
entity: resource,
|
||||
entityId: data.id,
|
||||
action: "created",
|
||||
created_by: req.user.user_id,
|
||||
tenant_id: req.user.tenant_id,
|
||||
oldVal: null,
|
||||
newVal: data,
|
||||
text: `${dataType.labelSingle} erstellt`,
|
||||
});*/
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
reply.status(500)
|
||||
}
|
||||
});
|
||||
|
||||
// UPDATE (inkl. Soft-Delete/Archive)
|
||||
server.put("/resource/:resource/:id", async (req, reply) => {
|
||||
try {
|
||||
const {resource, id} = req.params as { resource: string; id: string }
|
||||
const body = req.body as Record<string, any>
|
||||
|
||||
const tenantId = (req.user as any)?.tenant_id
|
||||
const userId = (req.user as any)?.user_id
|
||||
|
||||
if (!tenantId || !userId) {
|
||||
return reply.code(401).send({error: "Unauthorized"})
|
||||
}
|
||||
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
//TODO: HISTORY
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
let data = {...body, updated_at: new Date().toISOString(), updated_by: userId}
|
||||
|
||||
Object.keys(data).forEach((key) => {
|
||||
if(key.includes("_at") || key.includes("At")) {
|
||||
data[key] = normalizeDate(data[key])
|
||||
}
|
||||
})
|
||||
|
||||
console.log(data)
|
||||
|
||||
const [updated] = await server.db
|
||||
.update(table)
|
||||
.set(data)
|
||||
.where(and(
|
||||
eq(table.id, id),
|
||||
eq(table.tenant, tenantId)))
|
||||
.returning()
|
||||
|
||||
//const diffs = diffObjects(oldItem, newItem);
|
||||
|
||||
|
||||
/*for (const d of diffs) {
|
||||
await insertHistoryItem(server, {
|
||||
entity: resource,
|
||||
entityId: id,
|
||||
action: d.type,
|
||||
created_by: userId,
|
||||
tenant_id: tenantId,
|
||||
oldVal: d.oldValue ? String(d.oldValue) : null,
|
||||
newVal: d.newValue ? String(d.newValue) : null,
|
||||
text: `Feld "${d.label}" ${d.typeLabel}: ${d.oldValue ?? ""} → ${d.newValue ?? ""}`,
|
||||
});
|
||||
}*/
|
||||
|
||||
return updated
|
||||
} catch (err) {
|
||||
console.log("ERROR /resource/projects/:id", err)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
75
backend/src/routes/resourcesSpecial.ts
Normal file
75
backend/src/routes/resourcesSpecial.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import { sortData } from "../utils/sort"
|
||||
|
||||
// Schema imports
|
||||
import { accounts, units,countrys } from "../../db/schema"
|
||||
|
||||
const TABLE_MAP: Record<string, any> = {
|
||||
accounts,
|
||||
units,
|
||||
countrys,
|
||||
}
|
||||
|
||||
export default async function resourceRoutesSpecial(server: FastifyInstance) {
|
||||
|
||||
server.get("/resource-special/:resource", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { resource } = req.params as { resource: string }
|
||||
|
||||
// ❌ Wenn falsche Ressource
|
||||
if (!TABLE_MAP[resource]) {
|
||||
return reply.code(400).send({ error: "Invalid special resource" })
|
||||
}
|
||||
|
||||
const table = TABLE_MAP[resource]
|
||||
|
||||
const { select, sort, asc: ascQuery } = req.query as {
|
||||
select?: string
|
||||
sort?: string
|
||||
asc?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// 📌 SELECT: wir ignorieren select string (wie Supabase)
|
||||
// Drizzle kann kein dynamisches Select aus String!
|
||||
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
|
||||
// ---------------------------------------
|
||||
|
||||
let query = server.db.select().from(table)
|
||||
|
||||
// ---------------------------------------
|
||||
// 📌 Sortierung
|
||||
// ---------------------------------------
|
||||
if (sort) {
|
||||
const col = (table as any)[sort]
|
||||
if (col) {
|
||||
//@ts-ignore
|
||||
query =
|
||||
ascQuery === "true"
|
||||
? query.orderBy(asc(col))
|
||||
: query.orderBy(desc(col))
|
||||
}
|
||||
}
|
||||
|
||||
const data = await query
|
||||
|
||||
// Falls sort clientseitig wie früher notwendig ist:
|
||||
const sorted = sortData(
|
||||
data,
|
||||
sort,
|
||||
ascQuery === "true"
|
||||
)
|
||||
|
||||
return sorted
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
430
backend/src/routes/staff/time.ts
Normal file
430
backend/src/routes/staff/time.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import {
|
||||
stafftimeentries,
|
||||
stafftimenetryconnects
|
||||
} from "../../../db/schema"
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
desc
|
||||
} from "drizzle-orm"
|
||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||
import {z} from "zod";
|
||||
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
|
||||
|
||||
export default async function staffTimeRoutes(server: FastifyInstance) {
|
||||
|
||||
|
||||
server.post("/staff/time/event", async (req, reply) => {
|
||||
try {
|
||||
const userId = req.user.user_id
|
||||
const tenantId = req.user.tenant_id
|
||||
|
||||
const body = req.body as any
|
||||
|
||||
const normalizeDate = (val: any) => {
|
||||
if (!val) return null
|
||||
const d = new Date(val)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const dataToInsert = {
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
actortype: "user",
|
||||
actoruser_id: userId,
|
||||
eventtime: normalizeDate(body.eventtime),
|
||||
eventtype: body.eventtype,
|
||||
source: "WEB",
|
||||
payload: body.payload // Payload (z.B. Description) mit speichern
|
||||
}
|
||||
|
||||
console.log(dataToInsert)
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(dataToInsert)
|
||||
.returning()
|
||||
|
||||
return created
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
return reply.code(400).send({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 🆕 POST /staff/time/edit (Bearbeiten durch Invalidieren + Neu erstellen)
|
||||
server.post("/staff/time/edit", async (req, reply) => {
|
||||
try {
|
||||
const userId = req.user.user_id;
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
// Wir erwarten das komplette Paket für die Änderung
|
||||
const {
|
||||
originalEventIds, // Array der IDs, die "gelöscht" werden sollen (Start ID, End ID)
|
||||
newStart, // ISO String
|
||||
newEnd, // ISO String
|
||||
newType, // z.B. 'work', 'vacation'
|
||||
description,
|
||||
reason // Warum wurde geändert? (Audit)
|
||||
} = req.body as {
|
||||
originalEventIds: string[],
|
||||
newStart: string,
|
||||
newEnd: string | null,
|
||||
newType: string,
|
||||
description?: string,
|
||||
reason?: string
|
||||
};
|
||||
|
||||
if (!originalEventIds || originalEventIds.length === 0) {
|
||||
return reply.code(400).send({ error: "Keine Events zum Bearbeiten angegeben." });
|
||||
}
|
||||
|
||||
// 1. Transaction starten (damit alles oder nichts passiert)
|
||||
await server.db.transaction(async (tx) => {
|
||||
|
||||
// A. INVALIDIEREN (Die alten Events "löschen")
|
||||
// Wir erstellen für jedes alte Event ein 'invalidated' Event
|
||||
const invalidations = originalEventIds.map(id => ({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId, // Gehört dem Mitarbeiter
|
||||
actortype: "user",
|
||||
actoruser_id: userId, // Wer hat geändert?
|
||||
eventtime: new Date(),
|
||||
eventtype: "invalidated", // <--- NEUER TYP: Muss in loadValidEvents gefiltert werden!
|
||||
source: "WEB",
|
||||
related_event_id: id, // Zeigt auf das alte Event
|
||||
metadata: {
|
||||
reason: reason || "Bearbeitung",
|
||||
replaced_by_edit: true
|
||||
}
|
||||
}));
|
||||
|
||||
// Batch Insert
|
||||
// @ts-ignore
|
||||
await tx.insert(stafftimeevents).values(invalidations);
|
||||
|
||||
// B. NEU ERSTELLEN (Die korrigierten Events anlegen)
|
||||
|
||||
// Start Event
|
||||
// @ts-ignore
|
||||
await tx.insert(stafftimeevents).values({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
actortype: "user",
|
||||
actoruser_id: userId,
|
||||
eventtime: new Date(newStart),
|
||||
eventtype: `${newType}_start`, // z.B. work_start
|
||||
source: "WEB",
|
||||
payload: { description: description || "" }
|
||||
});
|
||||
|
||||
// End Event (nur wenn vorhanden)
|
||||
if (newEnd) {
|
||||
// @ts-ignore
|
||||
await tx.insert(stafftimeevents).values({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
actortype: "user",
|
||||
actoruser_id: userId,
|
||||
eventtime: new Date(newEnd),
|
||||
eventtype: `${newType}_end`, // z.B. work_end
|
||||
source: "WEB"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Fehler beim Bearbeiten:", err);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /staff/time/submit
|
||||
server.post("/staff/time/submit", async (req, reply) => {
|
||||
try {
|
||||
const userId = req.user.user_id; // Mitarbeiter, der einreicht
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
// Erwartet eine Liste von IDs der faktischen Events (work_start, work_end, etc.)
|
||||
const { eventIds } = req.body as { eventIds: string[] };
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return reply.code(400).send({ error: "Keine Events zum Einreichen angegeben." });
|
||||
}
|
||||
|
||||
const inserts = eventIds.map((eventId) => ({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId, // Event gehört zum Mitarbeiter
|
||||
actortype: "user",
|
||||
actoruser_id: userId, // Mitarbeiter ist der Akteur
|
||||
eventtime: new Date(),
|
||||
eventtype: "submitted", // NEU: Event-Typ für Einreichung
|
||||
source: "WEB",
|
||||
related_event_id: eventId, // Verweis auf das faktische Event
|
||||
}));
|
||||
|
||||
const createdEvents = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(inserts)
|
||||
.returning();
|
||||
|
||||
return { submittedCount: createdEvents.length };
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /staff/time/approve
|
||||
server.post("/staff/time/approve", async (req, reply) => {
|
||||
try {
|
||||
// 🚨 Berechtigungsprüfung (Voraussetzung: req.user enthält Manager-Status)
|
||||
/*if (!req.user.isManager) {
|
||||
return reply.code(403).send({ error: "Keine Genehmigungsberechtigung." });
|
||||
}*/
|
||||
|
||||
const actorId = req.user.user_id; // Manager ist der Akteur
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
const { eventIds, employeeUserId } = req.body as {
|
||||
eventIds: string[];
|
||||
employeeUserId: string; // Die ID des Mitarbeiters, dessen Zeit genehmigt wird
|
||||
};
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return reply.code(400).send({ error: "Keine Events zur Genehmigung angegeben." });
|
||||
}
|
||||
|
||||
const inserts = eventIds.map((eventId) => ({
|
||||
tenant_id: tenantId,
|
||||
user_id: employeeUserId, // Event gehört zum Mitarbeiter
|
||||
actortype: "user",
|
||||
actoruser_id: actorId, // Manager ist der Akteur
|
||||
eventtime: new Date(),
|
||||
eventtype: "approved", // NEU: Event-Typ für Genehmigung
|
||||
source: "WEB",
|
||||
related_event_id: eventId, // Verweis auf das faktische Event
|
||||
metadata: {
|
||||
// Optional: Genehmigungskommentar
|
||||
approvedBy: req.user.email
|
||||
}
|
||||
}));
|
||||
|
||||
const createdEvents = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(inserts)
|
||||
.returning();
|
||||
|
||||
return { approvedCount: createdEvents.length };
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /staff/time/reject
|
||||
server.post("/staff/time/reject", async (req, reply) => {
|
||||
try {
|
||||
// 🚨 Berechtigungsprüfung
|
||||
/*if (!req.user.isManager) {
|
||||
return reply.code(403).send({ error: "Keine Zurückweisungsberechtigung." });
|
||||
}*/
|
||||
|
||||
const actorId = req.user.user_id; // Manager ist der Akteur
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
const { eventIds, employeeUserId, reason } = req.body as {
|
||||
eventIds: string[];
|
||||
employeeUserId: string;
|
||||
reason?: string; // Optionaler Grund für die Ablehnung
|
||||
};
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return reply.code(400).send({ error: "Keine Events zur Ablehnung angegeben." });
|
||||
}
|
||||
|
||||
const inserts = eventIds.map((eventId) => ({
|
||||
tenant_id: tenantId,
|
||||
user_id: employeeUserId, // Event gehört zum Mitarbeiter
|
||||
actortype: "user",
|
||||
actoruser_id: actorId, // Manager ist der Akteur
|
||||
eventtime: new Date(),
|
||||
eventtype: "rejected", // NEU: Event-Typ für Ablehnung
|
||||
source: "WEB",
|
||||
related_event_id: eventId, // Verweis auf das faktische Event
|
||||
metadata: {
|
||||
reason: reason || "Ohne Angabe"
|
||||
}
|
||||
}));
|
||||
|
||||
const createdEvents = await server.db
|
||||
.insert(stafftimeevents)
|
||||
//@ts-ignore
|
||||
.values(inserts)
|
||||
.returning();
|
||||
|
||||
return { rejectedCount: createdEvents.length };
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/staff/time/spans
|
||||
server.get("/staff/time/spans", async (req, reply) => {
|
||||
try {
|
||||
// Der eingeloggte User (Anfragesteller)
|
||||
const actingUserId = req.user.user_id;
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
// Query-Parameter: targetUserId ist optional
|
||||
const { targetUserId } = req.query as { targetUserId?: string };
|
||||
|
||||
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
|
||||
const evaluatedUserId = targetUserId || actingUserId;
|
||||
|
||||
// 💡 "Unendlicher" Zeitraum, wie gewünscht
|
||||
const startDate = new Date(0); // 1970
|
||||
const endDate = new Date("2100-12-31");
|
||||
|
||||
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
|
||||
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
|
||||
const allEventsInTimeFrame = await loadValidEvents(
|
||||
server,
|
||||
tenantId,
|
||||
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// SCHRITT 2: Filtere faktische Events
|
||||
const FACTUAL_EVENT_TYPES = new Set([
|
||||
"work_start", "work_end", "pause_start", "pause_end",
|
||||
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
||||
"overtime_compensation_start", "overtime_compensation_end",
|
||||
"auto_stop"
|
||||
]);
|
||||
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
||||
|
||||
// SCHRITT 3: Hole administrative Events
|
||||
const factualEventIds = factualEvents.map(e => e.id);
|
||||
|
||||
if (factualEventIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
||||
|
||||
// SCHRITT 4: Kombinieren und Sortieren
|
||||
const combinedEvents = [
|
||||
...factualEvents,
|
||||
...relatedAdminEvents,
|
||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||
|
||||
// SCHRITT 5: Spans ableiten
|
||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||
|
||||
// SCHRITT 6: Spans anreichern
|
||||
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
||||
|
||||
return enrichedSpans;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden der Spans:", error);
|
||||
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
|
||||
}
|
||||
});
|
||||
|
||||
server.get("/staff/time/evaluation", async (req, reply) => {
|
||||
try {
|
||||
// --- 1. Eingangsdaten und Validierung des aktuellen Nutzers ---
|
||||
|
||||
// Daten des aktuell eingeloggten (anfragenden) Benutzers
|
||||
const actingUserId = req.user.user_id;
|
||||
const tenantId = req.user.tenant_id;
|
||||
|
||||
// Query-Parameter extrahieren
|
||||
const { from, to, targetUserId } = req.query as {
|
||||
from: string,
|
||||
to: string,
|
||||
targetUserId?: string // Optionale ID des Benutzers, dessen Daten abgerufen werden sollen
|
||||
};
|
||||
|
||||
// Die ID, für die die Auswertung tatsächlich durchgeführt wird
|
||||
const evaluatedUserId = targetUserId || actingUserId;
|
||||
|
||||
const startDate = new Date(from);
|
||||
const endDate = new Date(to);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return reply.code(400).send({ error: "Ungültiges Datumsformat." });
|
||||
}
|
||||
|
||||
// --- 3. Ausführung der Logik für den ermittelten Benutzer ---
|
||||
|
||||
// SCHRITT 1: Lade ALLE gültigen Events im Zeitraum
|
||||
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
|
||||
const allEventsInTimeFrame = await loadValidEvents(
|
||||
server, tenantId, evaluatedUserId, startDate, endDate // Verwendung der evaluatedUserId
|
||||
);
|
||||
|
||||
// 1b: Trenne Faktische und Administrative Events
|
||||
const FACTUAL_EVENT_TYPES = new Set([
|
||||
"work_start", "work_end", "pause_start", "pause_end",
|
||||
"sick_start", "sick_end", "vacation_start", "vacation_end",
|
||||
"overtime_compensation_start", "overtime_compensation_end",
|
||||
"auto_stop"
|
||||
]);
|
||||
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
|
||||
|
||||
// 1c: Sammle alle IDs der faktischen Events im Zeitraum
|
||||
const factualEventIds = factualEvents.map(e => e.id);
|
||||
|
||||
// SCHRITT 2: Lade die administrativen Events, die sich auf diese IDs beziehen (auch NACH dem Zeitraum)
|
||||
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
|
||||
|
||||
// SCHRITT 3: Kombiniere alle Events für die Weiterverarbeitung
|
||||
const combinedEvents = [
|
||||
...factualEvents,
|
||||
...relatedAdminEvents,
|
||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
||||
|
||||
// SCHRITT 4: Ableiten und Anreichern
|
||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
|
||||
|
||||
// SCHRITT 5: Erstellung der finalen Auswertung (Summen und Salden)
|
||||
const evaluationSummary = await buildTimeEvaluationFromSpans(
|
||||
server,
|
||||
evaluatedUserId, // Verwendung der evaluatedUserId
|
||||
tenantId,
|
||||
from,
|
||||
to,
|
||||
enrichedSpans
|
||||
);
|
||||
|
||||
return {
|
||||
userId: evaluatedUserId, // Rückgabe der ID, für die ausgewertet wurde
|
||||
spans: enrichedSpans,
|
||||
summary: evaluationSummary
|
||||
};
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error("Fehler in /staff/time/evaluation:", error);
|
||||
return reply.code(500).send({ error: "Interner Serverfehler bei der Zeitauswertung." });
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
71
backend/src/routes/staff/timeconnects.ts
Normal file
71
backend/src/routes/staff/timeconnects.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { FastifyInstance } from 'fastify'
|
||||
import { StaffTimeEntryConnect } from '../../types/staff'
|
||||
|
||||
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
|
||||
|
||||
// ▶ Connect anlegen
|
||||
server.post<{ Params: { id: string }, Body: Omit<StaffTimeEntryConnect, 'id' | 'time_entry_id'> }>(
|
||||
'/staff/time/:id/connects',
|
||||
async (req, reply) => {
|
||||
const { id } = req.params
|
||||
const { started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes } = req.body
|
||||
|
||||
const { data, error } = await server.supabase
|
||||
.from('staff_time_entry_connects')
|
||||
.insert([{ time_entry_id: id, started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes }])
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return reply.code(400).send({ error: error.message })
|
||||
return reply.send(data)
|
||||
}
|
||||
)
|
||||
|
||||
// ▶ Connects abrufen
|
||||
server.get<{ Params: { id: string } }>(
|
||||
'/staff/time/:id/connects',
|
||||
async (req, reply) => {
|
||||
const { id } = req.params
|
||||
const { data, error } = await server.supabase
|
||||
.from('staff_time_entry_connects')
|
||||
.select('*')
|
||||
.eq('time_entry_id', id)
|
||||
.order('started_at', { ascending: true })
|
||||
|
||||
if (error) return reply.code(400).send({ error: error.message })
|
||||
return reply.send(data)
|
||||
}
|
||||
)
|
||||
|
||||
// ▶ Connect aktualisieren
|
||||
server.patch<{ Params: { connectId: string }, Body: Partial<StaffTimeEntryConnect> }>(
|
||||
'/staff/time/connects/:connectId',
|
||||
async (req, reply) => {
|
||||
const { connectId } = req.params
|
||||
const { data, error } = await server.supabase
|
||||
.from('staff_time_entry_connects')
|
||||
.update({ ...req.body, updated_at: new Date().toISOString() })
|
||||
.eq('id', connectId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return reply.code(400).send({ error: error.message })
|
||||
return reply.send(data)
|
||||
}
|
||||
)
|
||||
|
||||
// ▶ Connect löschen
|
||||
server.delete<{ Params: { connectId: string } }>(
|
||||
'/staff/time/connects/:connectId',
|
||||
async (req, reply) => {
|
||||
const { connectId } = req.params
|
||||
const { error } = await server.supabase
|
||||
.from('staff_time_entry_connects')
|
||||
.delete()
|
||||
.eq('id', connectId)
|
||||
|
||||
if (error) return reply.code(400).send({ error: error.message })
|
||||
return reply.send({ success: true })
|
||||
}
|
||||
)
|
||||
}
|
||||
244
backend/src/routes/tenant.ts
Normal file
244
backend/src/routes/tenant.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { secrets } from "../utils/secrets"
|
||||
|
||||
import {
|
||||
authTenantUsers,
|
||||
authUsers,
|
||||
authProfiles,
|
||||
tenants
|
||||
} from "../../db/schema"
|
||||
|
||||
import {and, eq, inArray} from "drizzle-orm"
|
||||
|
||||
|
||||
export default async function tenantRoutes(server: FastifyInstance) {
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET CURRENT TENANT
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant", async (req) => {
|
||||
if (req.tenant) {
|
||||
return {
|
||||
message: `Hallo vom Tenant ${req.tenant?.name}`,
|
||||
tenant_id: req.tenant?.id,
|
||||
}
|
||||
}
|
||||
return {
|
||||
message: "Server ist im MultiTenant-Modus – es werden alle verfügbaren Tenants geladen."
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// SWITCH TENANT
|
||||
// -------------------------------------------------------------
|
||||
server.post("/tenant/switch", async (req, reply) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const { tenant_id } = req.body as { tenant_id: string }
|
||||
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
|
||||
|
||||
// prüfen ob der User zu diesem Tenant gehört
|
||||
const membership = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(and(
|
||||
eq(authTenantUsers.user_id, req.user.user_id),
|
||||
eq(authTenantUsers.tenant_id, Number(tenant_id))
|
||||
))
|
||||
|
||||
if (!membership.length) {
|
||||
return reply.code(403).send({ error: "Not a member of this tenant" })
|
||||
}
|
||||
|
||||
// JWT neu erzeugen
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: req.user.user_id,
|
||||
email: req.user.email,
|
||||
tenant_id,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: "6h" }
|
||||
)
|
||||
|
||||
reply.setCookie("token", token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 3,
|
||||
})
|
||||
|
||||
return { token }
|
||||
|
||||
} catch (err) {
|
||||
console.error("TENANT SWITCH ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT USERS (auth_users + auth_profiles)
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/users", async (req, reply) => {
|
||||
try {
|
||||
const authUser = req.user
|
||||
if (!authUser) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const tenantId = authUser.tenant_id
|
||||
|
||||
// 1) auth_tenant_users → user_ids
|
||||
const tenantUsers = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||||
|
||||
const userIds = tenantUsers.map(u => u.user_id)
|
||||
|
||||
if (!userIds.length) {
|
||||
return { tenant_id: tenantId, users: [] }
|
||||
}
|
||||
|
||||
// 2) auth_users laden
|
||||
const users = await server.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
|
||||
// 3) auth_profiles pro Tenant laden
|
||||
const profiles = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
|
||||
const combined = users.map(u => {
|
||||
const profile = profiles.find(p => p.user_id === u.id)
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
profile,
|
||||
full_name: profile?.full_name ?? null
|
||||
}
|
||||
})
|
||||
|
||||
return { tenant_id: tenantId, users: combined }
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/users ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// TENANT PROFILES
|
||||
// -------------------------------------------------------------
|
||||
server.get("/tenant/profiles", async (req, reply) => {
|
||||
try {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.tenant_id, tenantId))
|
||||
|
||||
return { data }
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/profiles ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UPDATE NUMBER RANGE
|
||||
// -------------------------------------------------------------
|
||||
server.put("/tenant/numberrange/:numberrange", async (req, reply) => {
|
||||
try {
|
||||
const user = req.user
|
||||
if (!user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { numberrange } = req.params as { numberrange: string }
|
||||
const { numberRange } = req.body as { numberRange: any }
|
||||
|
||||
if (!numberRange) {
|
||||
return reply.code(400).send({ error: "numberRange required" })
|
||||
}
|
||||
|
||||
const tenantId = Number(user.tenant_id)
|
||||
|
||||
const currentTenantRows = await server.db
|
||||
.select()
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
|
||||
const current = currentTenantRows[0]
|
||||
if (!current) return reply.code(404).send({ error: "Tenant not found" })
|
||||
|
||||
const updatedRanges = {
|
||||
//@ts-ignore
|
||||
...current.numberRanges,
|
||||
[numberrange]: numberRange
|
||||
}
|
||||
|
||||
const updated = await server.db
|
||||
.update(tenants)
|
||||
.set({ numberRanges: updatedRanges })
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.returning()
|
||||
|
||||
return updated[0]
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/numberrange ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UPDATE TENANT OTHER FIELDS
|
||||
// -------------------------------------------------------------
|
||||
server.put("/tenant/other/:id", async (req, reply) => {
|
||||
try {
|
||||
const user = req.user
|
||||
if (!user) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
const { data } = req.body as { data: any }
|
||||
|
||||
if (!data) return reply.code(400).send({ error: "data required" })
|
||||
|
||||
const updated = await server.db
|
||||
.update(tenants)
|
||||
.set(data)
|
||||
.where(eq(tenants.id, Number(user.tenant_id)))
|
||||
.returning()
|
||||
|
||||
return updated[0]
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/other ERROR:", err)
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
27
backend/src/types/staff.ts
Normal file
27
backend/src/types/staff.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface StaffTimeEntry {
|
||||
id: string
|
||||
tenant_id: string
|
||||
user_id: string
|
||||
started_at: string
|
||||
stopped_at?: string | null
|
||||
duration_minutes?: number | null
|
||||
type: 'work' | 'break' | 'absence' | 'other'
|
||||
description?: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface StaffTimeEntryConnect {
|
||||
id: string
|
||||
time_entry_id: string
|
||||
project_id?: string | null
|
||||
customer_id?: string | null
|
||||
task_id?: string | null
|
||||
ticket_id?: string | null
|
||||
started_at: string
|
||||
stopped_at: string
|
||||
duration_minutes?: number
|
||||
notes?: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
38
backend/src/utils/crypt.ts
Normal file
38
backend/src/utils/crypt.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import crypto from "crypto";
|
||||
import {secrets} from "./secrets"
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
|
||||
|
||||
|
||||
|
||||
export function encrypt(text) {
|
||||
const ENCRYPTION_KEY = Buffer.from(secrets.ENCRYPTION_KEY, "hex");
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
iv: iv.toString("hex"),
|
||||
content: encrypted.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
export function decrypt({ iv, content, tag }) {
|
||||
const ENCRYPTION_KEY = Buffer.from(secrets.ENCRYPTION_KEY, "hex");
|
||||
const decipher = crypto.createDecipheriv(
|
||||
ALGORITHM,
|
||||
ENCRYPTION_KEY,
|
||||
Buffer.from(iv, "hex")
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tag, "hex"));
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(content, "hex")),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
27
backend/src/utils/dbSearch.ts
Normal file
27
backend/src/utils/dbSearch.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ilike, or } from "drizzle-orm"
|
||||
|
||||
/**
|
||||
* Erzeugt eine OR-Suchbedingung über mehrere Spalten
|
||||
*
|
||||
* @param table - Drizzle Table Schema
|
||||
* @param columns - Array der Spaltennamen (property names im schema)
|
||||
* @param search - Suchbegriff
|
||||
*/
|
||||
export function buildSearchWhere(table: any, columns: string[], search: string) {
|
||||
if (!search || !columns.length) return undefined
|
||||
|
||||
const term = `%${search.toLowerCase()}%`
|
||||
|
||||
const parts = columns
|
||||
.map((colName) => {
|
||||
const col = table[colName]
|
||||
if (!col) return null
|
||||
return ilike(col, term)
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.length === 0) return undefined
|
||||
|
||||
// @ts-ignore
|
||||
return or(...parts)
|
||||
}
|
||||
103
backend/src/utils/diff.ts
Normal file
103
backend/src/utils/diff.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
import {diffTranslations} from "./diffTranslations";
|
||||
|
||||
export type DiffChange = {
|
||||
key: string;
|
||||
label: string;
|
||||
oldValue: any;
|
||||
newValue: any;
|
||||
type: "created" | "updated" | "deleted" | "unchanged";
|
||||
typeLabel: "erstellt" | "geändert" | "gelöscht" | "unverändert";
|
||||
};
|
||||
|
||||
const IGNORED_KEYS = new Set([
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"id",
|
||||
"phases"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Vergleicht zwei Objekte und gibt die Änderungen zurück.
|
||||
* @param obj1 Altes Objekt
|
||||
* @param obj2 Neues Objekt
|
||||
* @param ctx Lookup-Objekte (z. B. { projects, customers, vendors, profiles, plants })
|
||||
*/
|
||||
export function diffObjects(
|
||||
obj1: Record<string, any>,
|
||||
obj2: Record<string, any>,
|
||||
ctx: Record<string, any> = {}
|
||||
): DiffChange[] {
|
||||
const diffs: DiffChange[] = [];
|
||||
|
||||
const allKeys = new Set([
|
||||
...Object.keys(obj1 || {}),
|
||||
...Object.keys(obj2 || {}),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (IGNORED_KEYS.has(key)) continue; // Felder überspringen
|
||||
|
||||
const oldVal = obj1?.[key];
|
||||
const newVal = obj2?.[key];
|
||||
|
||||
console.log(oldVal, key, newVal);
|
||||
|
||||
// Wenn beides null/undefined → ignorieren
|
||||
if (
|
||||
(oldVal === null || oldVal === undefined || oldVal === "" || JSON.stringify(oldVal) === "[]") &&
|
||||
(newVal === null || newVal === undefined || newVal === "" || JSON.stringify(newVal) === "[]")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let type: DiffChange["type"] = "unchanged";
|
||||
let typeLabel: DiffChange["typeLabel"] = "unverändert";
|
||||
if (oldVal === newVal) {
|
||||
type = "unchanged";
|
||||
typeLabel = "unverändert";
|
||||
} else if (oldVal === undefined) {
|
||||
type = "created";
|
||||
typeLabel = "erstellt"
|
||||
} else if (newVal === undefined) {
|
||||
type = "deleted";
|
||||
typeLabel = "gelöscht"
|
||||
} else {
|
||||
type = "updated";
|
||||
typeLabel = "geändert"
|
||||
}
|
||||
|
||||
if (type === "unchanged") continue;
|
||||
|
||||
const translation = diffTranslations[key];
|
||||
let label = key;
|
||||
let resolvedOld = oldVal;
|
||||
let resolvedNew = newVal;
|
||||
|
||||
if (translation) {
|
||||
label = translation.label;
|
||||
if (translation.resolve) {
|
||||
const { oldVal: resOld, newVal: resNew } = translation.resolve(
|
||||
oldVal,
|
||||
newVal,
|
||||
ctx
|
||||
);
|
||||
resolvedOld = resOld;
|
||||
resolvedNew = resNew;
|
||||
}
|
||||
}
|
||||
|
||||
diffs.push({
|
||||
key,
|
||||
label,
|
||||
typeLabel,
|
||||
oldValue: resolvedOld ?? "-",
|
||||
newValue: resolvedNew ?? "-",
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
165
backend/src/utils/diffTranslations.ts
Normal file
165
backend/src/utils/diffTranslations.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
type ValueResolver = (
|
||||
oldVal: any,
|
||||
newVal: any,
|
||||
ctx?: Record<string, any>
|
||||
) => { oldVal: any; newVal: any };
|
||||
|
||||
export const diffTranslations: Record<
|
||||
string,
|
||||
{ label: string; resolve?: ValueResolver }
|
||||
> = {
|
||||
project: {
|
||||
label: "Projekt",
|
||||
resolve: (o, n, ctx) => ({
|
||||
oldVal: o ? ctx?.projects?.find((i: any) => i.id === o)?.name ?? "-" : "-",
|
||||
newVal: n ? ctx?.projects?.find((i: any) => i.id === n)?.name ?? "-" : "-",
|
||||
}),
|
||||
},
|
||||
title: { label: "Titel" },
|
||||
type: { label: "Typ" },
|
||||
notes: { label: "Notizen" },
|
||||
link: { label: "Link" },
|
||||
|
||||
start: {
|
||||
label: "Start",
|
||||
resolve: (o, n) => ({
|
||||
oldVal: o ? dayjs(o).format("DD.MM.YYYY HH:mm") : "-",
|
||||
newVal: n ? dayjs(n).format("DD.MM.YYYY HH:mm") : "-",
|
||||
}),
|
||||
},
|
||||
end: {
|
||||
label: "Ende",
|
||||
resolve: (o, n) => ({
|
||||
oldVal: o ? dayjs(o).format("DD.MM.YYYY HH:mm") : "-",
|
||||
newVal: n ? dayjs(n).format("DD.MM.YYYY HH:mm") : "-",
|
||||
}),
|
||||
},
|
||||
birthday: {
|
||||
label: "Geburtstag",
|
||||
resolve: (o, n) => ({
|
||||
oldVal: o ? dayjs(o).format("DD.MM.YYYY") : "-",
|
||||
newVal: n ? dayjs(n).format("DD.MM.YYYY") : "-",
|
||||
}),
|
||||
},
|
||||
resources: {
|
||||
label: "Resourcen",
|
||||
resolve: (o, n) => ({
|
||||
oldVal: Array.isArray(o) ? o.map((i: any) => i.title).join(", ") : "-",
|
||||
newVal: Array.isArray(n) ? n.map((i: any) => i.title).join(", ") : "-",
|
||||
}),
|
||||
},
|
||||
|
||||
customerNumber: { label: "Kundennummer" },
|
||||
active: {
|
||||
label: "Aktiv",
|
||||
resolve: (o, n) => ({
|
||||
oldVal: o === true ? "Aktiv" : "Gesperrt",
|
||||
newVal: n === true ? "Aktiv" : "Gesperrt",
|
||||
}),
|
||||
},
|
||||
isCompany: {
|
||||
label: "Firmenkunde",
|
||||
resolve: (o, n) => ({
|
||||
oldVal: o === true ? "Firma" : "Privatkunde",
|
||||
newVal: n === true ? "Firma" : "Privatkunde",
|
||||
}),
|
||||
},
|
||||
special: { label: "Adresszusatz" },
|
||||
street: { label: "Straße & Hausnummer" },
|
||||
city: { label: "Ort" },
|
||||
zip: { label: "Postleitzahl" },
|
||||
country: { label: "Land" },
|
||||
web: { label: "Webseite" },
|
||||
email: { label: "E-Mail" },
|
||||
tel: { label: "Telefon" },
|
||||
ustid: { label: "USt-ID" },
|
||||
role: { label: "Rolle" },
|
||||
phoneHome: { label: "Festnetz" },
|
||||
phoneMobile: { label: "Mobiltelefon" },
|
||||
salutation: { label: "Anrede" },
|
||||
firstName: { label: "Vorname" },
|
||||
lastName: { label: "Nachname" },
|
||||
name: { label: "Name" },
|
||||
nameAddition: { label: "Name Zusatz" },
|
||||
approved: { label: "Genehmigt" },
|
||||
manufacturer: { label: "Hersteller" },
|
||||
purchasePrice: { label: "Kaufpreis" },
|
||||
purchaseDate: { label: "Kaufdatum" },
|
||||
serialNumber: { label: "Seriennummer" },
|
||||
usePlanning: { label: "In Plantafel verwenden" },
|
||||
currentSpace: { label: "Lagerplatz" },
|
||||
|
||||
customer: {
|
||||
label: "Kunde",
|
||||
resolve: (o, n, ctx) => ({
|
||||
oldVal: o ? ctx?.customers?.find((i: any) => i.id === o)?.name ?? "-" : "-",
|
||||
newVal: n ? ctx?.customers?.find((i: any) => i.id === n)?.name ?? "-" : "-",
|
||||
}),
|
||||
},
|
||||
vendor: {
|
||||
label: "Lieferant",
|
||||
resolve: (o, n, ctx) => ({
|
||||
oldVal: o ? ctx?.vendors?.find((i: any) => i.id === o)?.name ?? "-" : "-",
|
||||
newVal: n ? ctx?.vendors?.find((i: any) => i.id === n)?.name ?? "-" : "-",
|
||||
}),
|
||||
},
|
||||
|
||||
description: { label: "Beschreibung" },
|
||||
categorie: { label: "Kategorie" },
|
||||
|
||||
profile: {
|
||||
label: "Mitarbeiter",
|
||||
resolve: (o, n, ctx) => ({
|
||||
oldVal: o ? ctx?.profiles?.find((i: any) => i.id === o)?.fullName ?? "-" : "-",
|
||||
newVal: n ? ctx?.profiles?.find((i: any) => i.id === n)?.fullName ?? "-" : "-",
|
||||
}),
|
||||
},
|
||||
plant: {
|
||||
label: "Objekt",
|
||||
resolve: (o, n, ctx) => ({
|
||||
oldVal: o ? ctx?.plants?.find((i: any) => i.id === o)?.name ?? "-" : "-",
|
||||
newVal: n ? ctx?.plants?.find((i: any) => i.id === n)?.name ?? "-" : "-",
|
||||
}),
|
||||
},
|
||||
|
||||
annualPaidLeaveDays: { label: "Urlaubstage" },
|
||||
employeeNumber: { label: "Mitarbeiternummer" },
|
||||
weeklyWorkingDays: { label: "Wöchentliche Arbeitstage" },
|
||||
weeklyWorkingHours: { label: "Wöchentliche Arbeitszeit" },
|
||||
customerRef: { label: "Referenz des Kunden" },
|
||||
|
||||
licensePlate: { label: "Kennzeichen" },
|
||||
tankSize: { label: "Tankvolumen" },
|
||||
towingCapacity: { label: "Anhängelast" },
|
||||
color: { label: "Farbe" },
|
||||
customPaymentDays: { label: "Zahlungsziel in Tagen" },
|
||||
customSurchargePercentage: { label: "Individueller Aufschlag" },
|
||||
powerInKW: { label: "Leistung" },
|
||||
|
||||
driver: {
|
||||
label: "Fahrer",
|
||||
resolve: (o, n, ctx) => ({
|
||||
oldVal: o ? ctx?.profiles?.find((i: any) => i.id === o)?.fullName ?? "-" : "-",
|
||||
newVal: n ? ctx?.profiles?.find((i: any) => i.id === n)?.fullName ?? "-" : "-",
|
||||
}),
|
||||
},
|
||||
|
||||
projecttype: { label: "Projekttyp" },
|
||||
|
||||
fixed: {
|
||||
label: "Festgeschrieben",
|
||||
resolve: (o, n) => ({
|
||||
oldVal: o === true ? "Ja" : "Nein",
|
||||
newVal: n === true ? "Ja" : "Nein",
|
||||
}),
|
||||
},
|
||||
archived: {
|
||||
label: "Archiviert",
|
||||
resolve: (o, n) => ({
|
||||
oldVal: o === true ? "Ja" : "Nein",
|
||||
newVal: n === true ? "Ja" : "Nein",
|
||||
}),
|
||||
},
|
||||
};
|
||||
45
backend/src/utils/emailengine.ts
Normal file
45
backend/src/utils/emailengine.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import axios from "axios"
|
||||
|
||||
const AxiosEE = axios.create({
|
||||
baseURL: process.env.EMAILENGINE_URL ||"https://ee.fedeo.io/v1",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.EMAILENGINE_TOKEN || "dcd8209bc5371c728f9ec951600afcfc74e8c391a7e984b2a6df9c4665dc7ad6"}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
export async function sendMailAsUser(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
text: string,
|
||||
account: string,
|
||||
cc: string,
|
||||
bcc: string,
|
||||
attachments: any,
|
||||
): Promise<{ success: boolean; info?: any; error?: any }> {
|
||||
try {
|
||||
const sendData = {
|
||||
to: to.split(";").map(i => { return {address: i}}),
|
||||
cc: cc ? cc.split(";").map((i:any) => { return {address: i}}) : null,
|
||||
bcc: bcc ? bcc.split(";").map((i:any) => { return {address: i}}) : null,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
attachments
|
||||
}
|
||||
|
||||
if(sendData.cc === null) delete sendData.cc
|
||||
if(sendData.bcc === null) delete sendData.bcc
|
||||
|
||||
const {data} = await AxiosEE.post(`/account/${account}/submit`, sendData)
|
||||
|
||||
return { success: true, info: data }
|
||||
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Mailversand:", err)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
}
|
||||
388
backend/src/utils/export/datev.ts
Normal file
388
backend/src/utils/export/datev.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import xmlbuilder from "xmlbuilder";
|
||||
import dayjs from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween.js"
|
||||
import {BlobWriter, Data64URIReader, TextReader, TextWriter, ZipWriter} from "@zip.js/zip.js";
|
||||
import {FastifyInstance} from "fastify";
|
||||
import {GetObjectCommand} from "@aws-sdk/client-s3";
|
||||
import {s3} from "../s3";
|
||||
import {secrets} from "../secrets";
|
||||
dayjs.extend(isBetween)
|
||||
|
||||
const getCreatedDocumentTotal = (item) => {
|
||||
let totalNet = 0
|
||||
let total19 = 0
|
||||
let total7 = 0
|
||||
|
||||
item.rows.forEach(row => {
|
||||
if(!['pagebreak','title','text'].includes(row.mode)){
|
||||
let rowPrice = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) /100) ).toFixed(3)
|
||||
totalNet = totalNet + Number(rowPrice)
|
||||
|
||||
if(row.taxPercent === 19) {
|
||||
// @ts-ignore
|
||||
total19 = total19 + Number(rowPrice * 0.19)
|
||||
} else if(row.taxPercent === 7) {
|
||||
// @ts-ignore
|
||||
total7 = total7 + Number(rowPrice * 0.07)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let totalGross = Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))
|
||||
|
||||
|
||||
|
||||
return {
|
||||
totalNet: totalNet,
|
||||
total19: total19,
|
||||
total7: total7,
|
||||
totalGross: totalGross,
|
||||
}
|
||||
}
|
||||
|
||||
const escapeString = (str) => {
|
||||
|
||||
str = (str ||"")
|
||||
.replaceAll("\n","")
|
||||
.replaceAll(";","")
|
||||
.replaceAll(/\r/g,"")
|
||||
.replaceAll(/"/g,"")
|
||||
.replaceAll(/ü/g,"ue")
|
||||
.replaceAll(/ä/g,"ae")
|
||||
.replaceAll(/ö/g,"oe")
|
||||
return str
|
||||
}
|
||||
|
||||
const displayCurrency = (input, onlyAbs = false) => {
|
||||
|
||||
if(onlyAbs) {
|
||||
return Math.abs(input).toFixed(2).replace(".",",")
|
||||
} else {
|
||||
return input.toFixed(2).replace(".",",")
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildExportZip(server: FastifyInstance, tenant: number, startDate: string, endDate: string, beraternr: string, mandantennr: string): Promise<Buffer> {
|
||||
|
||||
try {
|
||||
const zipFileWriter = new BlobWriter()
|
||||
const zipWriter = new ZipWriter(zipFileWriter)
|
||||
|
||||
|
||||
|
||||
//Basic Information
|
||||
|
||||
let header = `"EXTF";700;21;"Buchungsstapel";13;${dayjs().format("YYYYMMDDHHmmssSSS")};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${dayjs(startDate).format("YYYYMMDD")};${dayjs(endDate).format("YYYYMMDD")};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`
|
||||
|
||||
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`
|
||||
|
||||
//Get Bookings
|
||||
const {data:statementallocationsRaw,error: statementallocationsError} = await server.supabase.from("statementallocations").select('*, account(*), bs_id(*, account(*)), cd_id(*,customer(*)), ii_id(*, vendor(*)), vendor(*), customer(*), ownaccount(*)').eq("tenant", tenant);
|
||||
let {data:createddocumentsRaw,error: createddocumentsError} = await server.supabase.from("createddocuments").select('*,customer(*)').eq("tenant", tenant).in("type",["invoices","advanceInvoices","cancellationInvoices"]).eq("state","Gebucht").eq("archived",false)
|
||||
let {data:incominginvoicesRaw,error: incominginvoicesError} = await server.supabase.from("incominginvoices").select('*, vendor(*)').eq("tenant", tenant).eq("state","Gebucht").eq("archived",false)
|
||||
const {data:accounts} = await server.supabase.from("accounts").select()
|
||||
const {data:tenantData} = await server.supabase.from("tenants").select().eq("id",tenant).single()
|
||||
|
||||
let createddocuments = createddocumentsRaw.filter(i => dayjs(i.documentDate).isBetween(startDate,endDate,"day","[]"))
|
||||
let incominginvoices = incominginvoicesRaw.filter(i => dayjs(i.date).isBetween(startDate,endDate,"day","[]"))
|
||||
let statementallocations = statementallocationsRaw.filter(i => dayjs(i.bs_id.date).isBetween(startDate,endDate,"day","[]"))
|
||||
|
||||
|
||||
const {data:filesCreateddocuments, error: filesErrorCD} = await server.supabase.from("files").select().eq("tenant",tenant).or(`createddocument.in.(${createddocuments.map(i => i.id).join(",")})`)
|
||||
const {data:filesIncomingInvoices, error: filesErrorII} = await server.supabase.from("files").select().eq("tenant",tenant).or(`incominginvoice.in.(${incominginvoices.map(i => i.id).join(",")})`)
|
||||
|
||||
const downloadFile = async (bucketName, filePath, downloadFilePath,fileId) => {
|
||||
|
||||
console.log(filePath)
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: filePath,
|
||||
})
|
||||
|
||||
const { Body, ContentType } = await s3.send(command)
|
||||
|
||||
const chunks: any[] = []
|
||||
// @ts-ignore
|
||||
for await (const chunk of Body) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const buffer = Buffer.concat(chunks)
|
||||
|
||||
const dataURL = `data:application/pdf;base64,${buffer.toString('base64')}`
|
||||
|
||||
const dataURLReader = new Data64URIReader(dataURL)
|
||||
await zipWriter.add(`${fileId}.${downloadFilePath.split(".").pop()}`, dataURLReader)
|
||||
|
||||
//await fs.writeFile(`./output/${fileId}.${downloadFilePath.split(".").pop()}`, buffer, () => {});
|
||||
console.log(`File added to Zip`);
|
||||
};
|
||||
|
||||
for (const file of filesCreateddocuments) {
|
||||
await downloadFile("filesdev",file.path,`./output/files/${file.path.split("/")[file.path.split("/").length - 1]}`,file.id);
|
||||
}
|
||||
for (const file of filesIncomingInvoices) {
|
||||
await downloadFile("filesdev",file.path,`./output/files/${file.path.split("/")[file.path.split("/").length - 1]}`,file.id);
|
||||
}
|
||||
|
||||
let bookingLines = []
|
||||
|
||||
createddocuments.forEach(createddocument => {
|
||||
|
||||
let file = filesCreateddocuments.find(i => i.createddocument === createddocument.id);
|
||||
|
||||
let total = 0
|
||||
let typeString = ""
|
||||
|
||||
if(createddocument.type === "invoices") {
|
||||
total = getCreatedDocumentTotal(createddocument).totalGross
|
||||
|
||||
console.log()
|
||||
if(createddocument.usedAdvanceInvoices.length > 0){
|
||||
createddocument.usedAdvanceInvoices.forEach(usedAdvanceInvoice => {
|
||||
total -= getCreatedDocumentTotal(createddocumentsRaw.find(i => i.id === usedAdvanceInvoice)).totalGross
|
||||
})
|
||||
}
|
||||
|
||||
console.log(total)
|
||||
|
||||
typeString = "AR"
|
||||
} else if(createddocument.type === "advanceInvoices") {
|
||||
total = getCreatedDocumentTotal(createddocument).totalGross
|
||||
typeString = "ARAbschlag"
|
||||
} else if(createddocument.type === "cancellationInvoices") {
|
||||
total = getCreatedDocumentTotal(createddocument).totalGross
|
||||
typeString = "ARStorno"
|
||||
}
|
||||
|
||||
let shSelector = "S"
|
||||
if(Math.sign(total) === 1) {
|
||||
shSelector = "S"
|
||||
} else if (Math.sign(total) === -1) {
|
||||
shSelector = "H"
|
||||
}
|
||||
|
||||
bookingLines.push(`${displayCurrency(total,true)};"${shSelector}";;;;;${createddocument.customer.customerNumber};8400;"";${dayjs(createddocument.documentDate).format("DDMM")};"${createddocument.documentNumber}";;;"${`${typeString} ${createddocument.documentNumber} - ${createddocument.customer.name}`.substring(0,59)}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${createddocument.customer.name}";"Kundennummer";"${createddocument.customer.customerNumber}";"Belegnummer";"${createddocument.documentNumber}";"Leistungsdatum";"${dayjs(createddocument.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(createddocument.documentDate).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`)
|
||||
|
||||
})
|
||||
|
||||
incominginvoices.forEach(incominginvoice => {
|
||||
console.log(incominginvoice.id);
|
||||
incominginvoice.accounts.forEach(account => {
|
||||
|
||||
let file = filesIncomingInvoices.find(i => i.incominginvoice === incominginvoice.id);
|
||||
|
||||
|
||||
let accountData = accounts.find(i => i.id === account.account)
|
||||
let buschluessel: string = "9"
|
||||
|
||||
if(account.taxType === '19'){
|
||||
buschluessel = "9"
|
||||
} else if(account.taxType === 'null') {
|
||||
buschluessel = ""
|
||||
} else if(account.taxType === '7') {
|
||||
buschluessel = "8"
|
||||
} else if(account.taxType === '19I') {
|
||||
buschluessel = "19"
|
||||
} else if(account.taxType === '7I') {
|
||||
buschluessel = "18"
|
||||
} else {
|
||||
buschluessel = "-"
|
||||
}
|
||||
|
||||
let shSelector = "S"
|
||||
let amountGross = account.amountGross ? account.amountGross : account.amountNet + account.amountTax
|
||||
|
||||
|
||||
if(Math.sign(amountGross) === 1) {
|
||||
shSelector = "S"
|
||||
} else if(Math.sign(amountGross) === -1) {
|
||||
shSelector = "H"
|
||||
}
|
||||
|
||||
let text = `ER ${incominginvoice.reference}: ${escapeString(incominginvoice.description)}`.substring(0,59)
|
||||
console.log(incominginvoice)
|
||||
bookingLines.push(`${Math.abs(amountGross).toFixed(2).replace(".",",")};"${shSelector}";;;;;${accountData.number};${incominginvoice.vendor.vendorNumber};"${buschluessel}";${dayjs(incominginvoice.date).format("DDMM")};"${incominginvoice.reference}";;;"${text}";;;;;;${file ? `"BEDI ""${file.id}"""` : ""};"Geschäftspartner";"${incominginvoice.vendor.name}";"Kundennummer";"${incominginvoice.vendor.vendorNumber}";"Belegnummer";"${incominginvoice.reference}";"Leistungsdatum";"${dayjs(incominginvoice.date).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(incominginvoice.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`)
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
statementallocations.forEach(statementallocation => {
|
||||
|
||||
let shSelector = "S"
|
||||
|
||||
if(Math.sign(statementallocation.amount) === 1) {
|
||||
shSelector = "S"
|
||||
} else if(Math.sign(statementallocation.amount) === -1) {
|
||||
shSelector = "H"
|
||||
}
|
||||
|
||||
if(statementallocation.cd_id) {
|
||||
bookingLines.push(`${displayCurrency(statementallocation.amount,true)};"H";;;;;${statementallocation.cd_id.customer.customerNumber};${statementallocation.bs_id.account.datevNumber};"3";${dayjs(statementallocation.cd_id.documentDate).format("DDMM")};"${statementallocation.cd_id.documentNumber}";;;"${`ZE${statementallocation.description}${escapeString(statementallocation.bs_id.text)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${statementallocation.cd_id.customer.name}";"Kundennummer";"${statementallocation.cd_id.customer.customerNumber}";"Belegnummer";"${statementallocation.cd_id.documentNumber}";"Leistungsdatum";"${dayjs(statementallocation.cd_id.deliveryDate).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(statementallocation.cd_id.documentDate).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`)
|
||||
} else if(statementallocation.ii_id) {
|
||||
bookingLines.push(`${displayCurrency(statementallocation.amount,true)};"${shSelector}";;;;;${statementallocation.bs_id.account.datevNumber};${statementallocation.ii_id.vendor.vendorNumber};"";${dayjs(statementallocation.ii_id.date).format("DDMM")};"${statementallocation.ii_id.reference}";;;"${`ZA${statementallocation.description} ${escapeString(statementallocation.bs_id.text)} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${statementallocation.ii_id.vendor.name}";"Kundennummer";"${statementallocation.ii_id.vendor.vendorNumber}";"Belegnummer";"${statementallocation.ii_id.reference}";"Leistungsdatum";"${dayjs(statementallocation.ii_id.date).format("DD.MM.YYYY")}";"Belegdatum";"${dayjs(statementallocation.ii_id.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`)
|
||||
} else if(statementallocation.account) {
|
||||
bookingLines.push(`${displayCurrency(statementallocation.amount,true)};"${shSelector}";;;;;${statementallocation.bs_id.account.datevNumber};${statementallocation.account.number};"";${dayjs(statementallocation.bs_id.date).format("DDMM")};"";;;"${`${Math.sign(statementallocation.amount) > 0 ? "ZE" : "ZA"} ${statementallocation.account.number} - ${escapeString(statementallocation.account.label)}${escapeString(statementallocation.description)}${statementallocation.bs_id.text}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${statementallocation.bs_id.credName}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dayjs(statementallocation.bs_id.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`)
|
||||
} else if(statementallocation.vendor) {
|
||||
bookingLines.push(`${displayCurrency(statementallocation.amount,true)};"${shSelector}";;;;;${statementallocation.bs_id.account.datevNumber};${statementallocation.vendor.vendorNumber};"";${dayjs(statementallocation.bs_id.date).format("DDMM")};"";;;"${`${Math.sign(statementallocation.amount) > 0 ? "ZE" : "ZA"} ${statementallocation.vendor.vendorNumber} - ${escapeString(statementallocation.vendor.name)}${escapeString(statementallocation.description)}${statementallocation.bs_id.text}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${statementallocation.vendor.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dayjs(statementallocation.bs_id.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`)
|
||||
} else if(statementallocation.customer) {
|
||||
bookingLines.push(`${displayCurrency(statementallocation.amount,true)};"${shSelector}";;;;;${statementallocation.bs_id.account.datevNumber};${statementallocation.customer.customerNumber};"";${dayjs(statementallocation.bs_id.date).format("DDMM")};"";;;"${`${Math.sign(statementallocation.amount) > 0 ? "ZE" : "ZA"} ${statementallocation.customer.customerNumber} - ${escapeString(statementallocation.customer.name)}${escapeString(statementallocation.description)}${statementallocation.bs_id.text}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${statementallocation.customer.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dayjs(statementallocation.bs_id.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`)
|
||||
} else if(statementallocation.ownaccount) {
|
||||
bookingLines.push(`${displayCurrency(statementallocation.amount,true)};"${shSelector}";;;;;${statementallocation.bs_id.account.datevNumber};${statementallocation.ownaccount.number};"";${dayjs(statementallocation.bs_id.date).format("DDMM")};"";;;"${`${Math.sign(statementallocation.amount) > 0 ? "ZE" : "ZA"} ${statementallocation.ownaccount.number} - ${escapeString(statementallocation.ownaccount.name)}${escapeString(statementallocation.description)}${statementallocation.bs_id.text}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${statementallocation.ownaccount.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dayjs(statementallocation.bs_id.date).format("DD.MM.YYYY")}";;;;;;;;;;"";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;;`)
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
let csvString = `${header}\n${colHeaders}\n`;
|
||||
bookingLines.forEach(line => {
|
||||
csvString += `${line}\n`;
|
||||
})
|
||||
|
||||
const buchungsstapelReader = new TextReader(csvString)
|
||||
await zipWriter.add(`EXTF_Buchungsstapel_von_${dayjs(startDate).format("DDMMYYYY")}_bis_${dayjs(endDate).format("DDMMYYYY")}.csv`, buchungsstapelReader)
|
||||
|
||||
/*fs.writeFile(`output/EXTF_Buchungsstapel_von_${dayjs(startDate).format("DDMMYYYY")}_bis_${dayjs(endDate).format("DDMMYYYY")}.csv`, csvString, 'utf8', function (err) {
|
||||
if (err) {
|
||||
console.log('Some error occured - file either not saved or corrupted file saved.');
|
||||
console.log(err);
|
||||
} else{
|
||||
console.log('It\'s saved!');
|
||||
}
|
||||
});*/
|
||||
|
||||
// Kreditoren/Debitoren
|
||||
let headerStammdaten = `"EXTF";700;16;"Debitoren/Kreditoren";5;${dayjs().format("YYYYMMDDHHmmssSSS")};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${dayjs(startDate).format("YYYYMMDD")};${dayjs(endDate).format("YYYYMMDD")};"Debitoren & Kreditoren";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`
|
||||
|
||||
let colHeadersStammdaten = `Konto;Name (Adressattyp Unternehmen);Unternehmensgegenstand;Name (Adressattyp natuerl. Person);Vorname (Adressattyp natuerl. Person);Name (Adressattyp keine Angabe);Adressatentyp;Kurzbezeichnung;EU-Land;EU-UStID;Anrede;Titel/Akad. Grad;Adelstitel;Namensvorsatz;Adressart;Strasse;Postfach;Postleitzahl;Ort;Land;Versandzusatz;Adresszusatz;Abweichende Anrede;Abw. Zustellbezeichnung 1;Abw. Zustellbezeichnung 2;Kennz. Korrespondenzadresse;Adresse Gueltig von;Adresse Gueltig bis;Telefon;Bemerkung (Telefon);Telefon GL;Bemerkung (Telefon GL);E-Mail;Bemerkung (E-Mail);Internet;Bemerkung (Internet);Fax;Bemerkung (Fax);Sonstige;Bemerkung (Sonstige);Bankleitzahl 1;Bankbezeichnung 1;Bankkonto-Nummer 1;Laenderkennzeichen 1;IBAN-Nr. 1;Leerfeld;SWIFT-Code 1;Abw. Kontoinhaber 1;Kennz. Haupt-Bankverb. 1;Bankverb. 1 Gueltig von;Bankverb. 1 Gueltig bis;Bankleitzahl 2;Bankbezeichnung 2;Bankkonto-Nummer 2;Laenderkennzeichen 2;IBAN-Nr. 2;Leerfeld;SWIFT-Code 2;Abw. Kontoinhaber 2;Kennz. Haupt-Bankverb. 2;Bankverb. 2 gueltig von;Bankverb. 2 gueltig bis;Bankleitzahl 3;Bankbezeichnung 3;Bankkonto-Nummer 3;Laenderkennzeichen 3;IBAN-Nr. 3;Leerfeld;SWIFT-Code 3;Abw. Kontoinhaber 3;Kennz. Haupt-Bankverb. 3;Bankverb. 3 gueltig von;Bankverb. 3 gueltig bis;Bankleitzahl 4;Bankbezeichnung 4;Bankkonto-Nummer 4;Laenderkennzeichen 4;IBAN-Nr. 4;Leerfeld;SWIFT-Code 4;Abw. Kontoinhaber 4;Kennz. Haupt-Bankverb. 4;Bankverb. 4 gueltig von;Bankverb. 4 gueltig bis;Bankleitzahl 5;Bankbezeichnung 5;Bankkonto-Nummer 5;Laenderkennzeichen 5;IBAN-Nr. 5;Leerfeld;SWIFT-Code 5;Abw. Kontoinhaber 5;Kennz. Haupt-Bankverb. 5;Bankverb. 5 gueltig von;Bankverb. 5 gueltig bis;Leerfeld;Briefanrede;Grussformel;Kunden-/Lief.-Nr.;Steuernummer;Sprache;Ansprechpartner;Vertreter;Sachbearbeiter;Diverse-Konto;Ausgabeziel;Waehrungssteuerung;Kreditlimit (Debitor);Zahlungsbedingung;Faelligkeit in Tagen (Debitor);Skonto in Prozent (Debitor);Kreditoren-Ziel 1 Tg.;Kreditoren-Skonto 1 %;Kreditoren-Ziel 2 Tg.;Kreditoren-Skonto 2 %;Kreditoren-Ziel 3 Brutto Tg.;Kreditoren-Ziel 4 Tg.;Kreditoren-Skonto 4 %;Kreditoren-Ziel 5 Tg.;Kreditoren-Skonto 5 %;Mahnung;Kontoauszug;Mahntext 1;Mahntext 2;Mahntext 3;Kontoauszugstext;Mahnlimit Betrag;Mahnlimit %;Zinsberechnung;Mahnzinssatz 1;Mahnzinssatz 2;Mahnzinssatz 3;Lastschrift;Verfahren;Mandantenbank;Zahlungstraeger;Indiv. Feld 1;Indiv. Feld 2;Indiv. Feld 3;Indiv. Feld 4;Indiv. Feld 5;Indiv. Feld 6;Indiv. Feld 7;Indiv. Feld 8;Indiv. Feld 9;Indiv. Feld 10;Indiv. Feld 11;Indiv. Feld 12;Indiv. Feld 13;Indiv. Feld 14;Indiv. Feld 15;Abweichende Anrede (Rechnungsadresse);Adressart (Rechnungsadresse);Strasse (Rechnungsadresse);Postfach (Rechnungsadresse);Postleitzahl (Rechnungsadresse);Ort (Rechnungsadresse);Land (Rechnungsadresse);Versandzusatz (Rechnungsadresse);Adresszusatz (Rechnungsadresse);Abw. Zustellbezeichnung 1 (Rechnungsadresse);Abw. Zustellbezeichnung 2 (Rechnungsadresse);Adresse Gueltig von (Rechnungsadresse);Adresse Gueltig bis (Rechnungsadresse);Bankleitzahl 6;Bankbezeichnung 6;Bankkonto-Nummer 6;Laenderkennzeichen 6;IBAN-Nr. 6;Leerfeld;SWIFT-Code 6;Abw. Kontoinhaber 6;Kennz. Haupt-Bankverb. 6;Bankverb. 6 gueltig von;Bankverb. 6 gueltig bis;Bankleitzahl 7;Bankbezeichnung 7;Bankkonto-Nummer 7;Laenderkennzeichen 7;IBAN-Nr. 7;Leerfeld;SWIFT-Code 7;Abw. Kontoinhaber 7;Kennz. Haupt-Bankverb. 7;Bankverb. 7 gueltig von;Bankverb. 7 gueltig bis;Bankleitzahl 8;Bankbezeichnung 8;Bankkonto-Nummer 8;Laenderkennzeichen 8;IBAN-Nr. 8;Leerfeld;SWIFT-Code 8;Abw. Kontoinhaber 8;Kennz. Haupt-Bankverb. 8;Bankverb. 8 gueltig von;Bankverb. 8 gueltig bis;Bankleitzahl 9;Bankbezeichnung 9;Bankkonto-Nummer 9;Laenderkennzeichen 9;IBAN-Nr. 9;Leerfeld;SWIFT-Code 9;Abw. Kontoinhaber 9;Kennz. Haupt-Bankverb. 9;Bankverb. 9 gueltig von;Bankverb. 9 gueltig bis;Bankleitzahl 10;Bankbezeichnung 10;Bankkonto-Nummer 10;Laenderkennzeichen 10;IBAN-Nr. 10;Leerfeld;SWIFT-Code 10;Abw. Kontoinhaber 10;Kennz. Haupt-Bankverb. 10;Bankverb 10 Gueltig von;Bankverb 10 Gueltig bis;Nummer Fremdsystem;Insolvent;SEPA-Mandatsreferenz 1;SEPA-Mandatsreferenz 2;SEPA-Mandatsreferenz 3;SEPA-Mandatsreferenz 4;SEPA-Mandatsreferenz 5;SEPA-Mandatsreferenz 6;SEPA-Mandatsreferenz 7;SEPA-Mandatsreferenz 8;SEPA-Mandatsreferenz 9;SEPA-Mandatsreferenz 10;Verknuepftes OPOS-Konto;Mahnsperre bis;Lastschriftsperre bis;Zahlungssperre bis;Gebuehrenberechnung;Mahngebuehr 1;Mahngebuehr 2;Mahngebuehr 3;Pauschalberechnung;Verzugspauschale 1;Verzugspauschale 2;Verzugspauschale 3;Alternativer Suchname;Status;Anschrift manuell geaendert (Korrespondenzadresse);Anschrift individuell (Korrespondenzadresse);Anschrift manuell geaendert (Rechnungsadresse);Anschrift individuell (Rechnungsadresse);Fristberechnung bei Debitor;Mahnfrist 1;Mahnfrist 2;Mahnfrist 3;Letzte Frist`
|
||||
const {data:customers} = await server.supabase.from("customers").select().eq("tenant",tenant).order("customerNumber")
|
||||
const {data:vendors} = await server.supabase.from("vendors").select().eq("tenant",tenant).order("vendorNumber")
|
||||
|
||||
let bookinglinesStammdaten = []
|
||||
|
||||
customers.forEach(customer => {
|
||||
bookinglinesStammdaten.push(`${customer.customerNumber};"${customer.isCompany ? customer.name.substring(0,48): ''}";;"${!customer.isCompany ? (customer.lastname ? customer.lastname : customer.name) : ''}";"${!customer.isCompany ? (customer.firstname ? customer.firstname : '') : ''}";;${customer.isCompany ? 2 : 1};;;;;;;;"STR";"${customer.infoData.street ? customer.infoData.street : ''}";;"${customer.infoData.zip ? customer.infoData.zip : ''}";"${customer.infoData.city ? customer.infoData.city : ''}";;;"${customer.infoData.special ? customer.infoData.special : ''}";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;`)
|
||||
|
||||
})
|
||||
|
||||
vendors.forEach(vendor => {
|
||||
bookinglinesStammdaten.push(`${vendor.vendorNumber};"${vendor.name.substring(0,48)}";;;;;2;;;;;;;;"STR";"${vendor.infoData.street ? vendor.infoData.street : ''}";;"${vendor.infoData.zip ? vendor.infoData.zip : ''}";"${vendor.infoData.city ? vendor.infoData.city : ''}";;;"${vendor.infoData.special ? vendor.infoData.special : ''}";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;`)
|
||||
|
||||
})
|
||||
|
||||
let csvStringStammdaten = `${headerStammdaten}\n${colHeadersStammdaten}\n`;
|
||||
bookinglinesStammdaten.forEach(line => {
|
||||
csvStringStammdaten += `${line}\n`;
|
||||
})
|
||||
|
||||
const stammdatenReader = new TextReader(csvStringStammdaten)
|
||||
await zipWriter.add(`EXTF_Stammdaten_von_${dayjs(startDate).format("DDMMYYYY")}_bis_${dayjs(endDate).format("DDMMYYYY")}.csv`, stammdatenReader)
|
||||
|
||||
|
||||
|
||||
/*fs.writeFile(`output/EXTF_Stammdaten_von_${dayjs(startDate).format("DDMMYYYY")}_bis_${dayjs(endDate).format("DDMMYYYY")}.csv`, csvStringStammdaten, 'utf8', function (err) {
|
||||
if (err) {
|
||||
console.log('Some error occured - file either not saved or corrupted file saved.');
|
||||
console.log(err);
|
||||
} else{
|
||||
console.log('It\'s saved!');
|
||||
}
|
||||
});*/
|
||||
|
||||
//Sachkonten
|
||||
let headerSachkonten = `"EXTF";700;20;"Kontenbeschriftungen";3;${dayjs().format("YYYYMMDDHHmmssSSS")};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${dayjs(startDate).format("YYYYMMDD")};${dayjs(endDate).format("YYYYMMDD")};"Sachkonten";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`
|
||||
|
||||
let colHeadersSachkonten = `Konto;Kontenbeschriftung;Sprach-ID;Kontenbeschriftung lang`
|
||||
const {data:bankaccounts} = await server.supabase.from("bankaccounts").select().eq("tenant",tenant).order("datevNumber")
|
||||
|
||||
let bookinglinesSachkonten = []
|
||||
|
||||
bankaccounts.forEach(bankaccount => {
|
||||
bookinglinesSachkonten.push(`${bankaccount.datevNumber};"${bankaccount.name}";"de-DE";`)
|
||||
|
||||
})
|
||||
|
||||
let csvStringSachkonten = `${headerSachkonten}\n${colHeadersSachkonten}\n`;
|
||||
bookinglinesSachkonten.forEach(line => {
|
||||
csvStringSachkonten += `${line}\n`;
|
||||
})
|
||||
|
||||
const sachkontenReader = new TextReader(csvStringSachkonten)
|
||||
await zipWriter.add(`EXTF_Sachkonten_von_${dayjs(startDate).format("DDMMYYYY")}_bis_${dayjs(endDate).format("DDMMYYYY")}.csv`, sachkontenReader)
|
||||
|
||||
/*fs.writeFile(`output/EXTF_Sachkonten_von_${dayjs(startDate).format("DDMMYYYY")}_bis_${dayjs(endDate).format("DDMMYYYY")}.csv`, csvStringSachkonten, 'utf8', function (err) {
|
||||
if (err) {
|
||||
console.log('Some error occured - file either not saved or corrupted file saved.');
|
||||
console.log(err);
|
||||
} else{
|
||||
console.log('It\'s saved!');
|
||||
}
|
||||
});*/
|
||||
|
||||
|
||||
let obj = {
|
||||
archive: {
|
||||
'@version':"5.0",
|
||||
"@generatingSystem":"fedeo.de",
|
||||
"@xsi:schemaLocation":"http://xml.datev.de/bedi/tps/document/v05.0 Document_v050.xsd",
|
||||
"@xmlns":"http://xml.datev.de/bedi/tps/document/v05.0",
|
||||
"@xmlns:xsi":"http://www.w3.org/2001/XMLSchema-instance",
|
||||
header: {
|
||||
date: dayjs().format("YYYY-MM-DDTHH:mm:ss")
|
||||
},
|
||||
content: {
|
||||
document: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filesCreateddocuments.forEach(file => {
|
||||
obj.archive.content.document.push({
|
||||
"@guid": file.id,
|
||||
extension: {
|
||||
"@xsi:type":"File",
|
||||
"@name":`${file.id}.pdf`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
filesIncomingInvoices.forEach(file => {
|
||||
obj.archive.content.document.push({
|
||||
"@guid": file.id,
|
||||
extension: {
|
||||
"@xsi:type":"File",
|
||||
"@name":`${file.id}.pdf`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let doc = xmlbuilder.create(obj, {encoding: 'UTF-8', standalone: true})
|
||||
|
||||
//console.log(doc.end({pretty: true}));
|
||||
|
||||
const documentsReader = new TextReader(doc.end({pretty: true}))
|
||||
await zipWriter.add(`document.xml`, documentsReader)
|
||||
|
||||
|
||||
|
||||
|
||||
/*function toBuffer(arrayBuffer) {
|
||||
const buffer = Buffer.alloc(arrayBuffer.byteLength);
|
||||
const view = new Uint8Array(arrayBuffer);
|
||||
for (let i = 0; i < buffer.length; ++i) {
|
||||
buffer[i] = view[i];
|
||||
}
|
||||
return buffer;
|
||||
}*/
|
||||
|
||||
|
||||
const arrayBuffer = await (await zipWriter.close()).arrayBuffer()
|
||||
return Buffer.from(arrayBuffer)
|
||||
} catch(error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
114
backend/src/utils/export/sepa.ts
Normal file
114
backend/src/utils/export/sepa.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import xmlbuilder from "xmlbuilder";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export const createSEPAExport = async (server,idsToExport, tenant_id) => {
|
||||
const {data,error} = await server.supabase.from("createddocuments").select().eq("tenant", tenant_id).in("id", idsToExport)
|
||||
const {data:tenantData,error:tenantError} = await server.supabase.from("tenants").select().eq("id", tenant_id).single()
|
||||
console.log(tenantData)
|
||||
console.log(tenantError)
|
||||
|
||||
console.log(data)
|
||||
|
||||
let transactions = []
|
||||
|
||||
let obj = {
|
||||
Document: {
|
||||
'@xmlns':"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02",
|
||||
'CstmrDrctDbtInitn': {
|
||||
'GrpHdr': {
|
||||
'MsgId': randomUUID(),
|
||||
'CredDtTm': dayjs().format("YYYY-MM-DDTHH:mm:ss"),
|
||||
'NbOfTxs': transactions.length,
|
||||
'CtrlSum': 0, // TODO: Total Sum
|
||||
'InitgPty': {
|
||||
'Nm': tenantData.name
|
||||
}
|
||||
},
|
||||
'PmtInf': {
|
||||
'PmtInfId': "", // TODO: Mandatsreferenz,
|
||||
'PmtMtd': "DD",
|
||||
'BtchBookg': "true", // TODO: BatchBooking,
|
||||
'NbOfTxs': transactions.length,
|
||||
'CtrlSum': 0, //TODO: Total Sum
|
||||
'PmtTpInf': {
|
||||
'SvcLvl': {
|
||||
'Cd': "SEPA"
|
||||
},
|
||||
'LclInstrm': {
|
||||
'Cd': "CORE" // Core für BASIS / B2B für Firmen
|
||||
},
|
||||
'SeqTp': "RCUR" // TODO: Unterscheidung Einmalig / Wiederkehrend
|
||||
},
|
||||
'ReqdColltnDt': dayjs().add(3, "days").format("YYYY-MM-DDTHH:mm:ss"),
|
||||
'Cdtr': {
|
||||
'Nm': tenantData.name
|
||||
},
|
||||
'CdtrAcct': {
|
||||
'Id': {
|
||||
'IBAN': "DE" // TODO: IBAN Gläubiger EINSETZEN
|
||||
}
|
||||
},
|
||||
'CdtrAgt': {
|
||||
'FinInstnId': {
|
||||
'BIC': "BIC" // TODO: BIC des Gläubigers einsetzen
|
||||
}
|
||||
},
|
||||
'ChrgBr': "SLEV",
|
||||
'CdtrSchmeId': {
|
||||
'Id': {
|
||||
'PrvtId': {
|
||||
'Othr': {
|
||||
'Id': tenantData.creditorId,
|
||||
'SchmeNm': {
|
||||
'Prty': "SEPA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
//TODO ITERATE ALL INVOICES HERE
|
||||
'DrctDbtTxInf': {
|
||||
'PmtId': {
|
||||
'EndToEndId': "" // TODO: EINDEUTIGE ReFERENZ wahrscheinlich RE Nummer
|
||||
},
|
||||
'InstdAmt': {
|
||||
'@Ccy':"EUR",
|
||||
'#text':100 //TODO: Rechnungssumme zwei NK mit Punkt
|
||||
},
|
||||
'DrctDbtTx': {
|
||||
'MndtRltdInf': {
|
||||
'MndtId': "", // TODO: Mandatsref,
|
||||
'DtOfSgntr': "", //TODO: Unterschrieben am,
|
||||
'AmdmntInd': "" //TODO: Mandat geändert
|
||||
}
|
||||
},
|
||||
'DbtrAgt': {
|
||||
'FinInstnId': {
|
||||
'BIC': "", //TODO: BIC Debtor
|
||||
}
|
||||
},
|
||||
'Dbtr': {
|
||||
'Nm': "" // TODO NAME Debtor
|
||||
},
|
||||
'DbtrAcct': {
|
||||
'Id': {
|
||||
'IBAN': "DE" // TODO IBAN Debtor
|
||||
}
|
||||
},
|
||||
'RmtInf': {
|
||||
'Ustrd': "" //TODO Verwendungszweck Rechnungsnummer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let doc = xmlbuilder.create(obj, {encoding: 'UTF-8', standalone: true})
|
||||
|
||||
console.log(doc.end({pretty:true}))
|
||||
|
||||
}
|
||||
95
backend/src/utils/files.ts
Normal file
95
backend/src/utils/files.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3"
|
||||
import { s3 } from "./s3"
|
||||
import { secrets } from "./secrets"
|
||||
|
||||
// Drizzle schema
|
||||
import { files } from "../../db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { FastifyInstance } from "fastify"
|
||||
|
||||
export const saveFile = async (
|
||||
server: FastifyInstance,
|
||||
tenant: number,
|
||||
messageId: string | number | null, // Typ angepasst (oft null bei manueller Gen)
|
||||
attachment: any, // Kann File, Buffer oder Mailparser-Objekt sein
|
||||
folder: string | null,
|
||||
type: string | null,
|
||||
other: Record<string, any> = {}
|
||||
) => {
|
||||
try {
|
||||
// ---------------------------------------------------
|
||||
// 1️⃣ FILE ENTRY ANLEGEN
|
||||
// ---------------------------------------------------
|
||||
const insertRes = await server.db
|
||||
.insert(files)
|
||||
.values({
|
||||
tenant,
|
||||
folder,
|
||||
type,
|
||||
...other
|
||||
})
|
||||
.returning()
|
||||
|
||||
const created = insertRes?.[0]
|
||||
if (!created) {
|
||||
console.error("File creation failed (no row returned)")
|
||||
return null
|
||||
}
|
||||
|
||||
// Name ermitteln (Fallback Logik)
|
||||
// Wenn attachment ein Buffer ist, muss der Name in 'other' stehen oder generiert werden
|
||||
const filename = attachment.filename || other.filename || `${created.id}.pdf`
|
||||
|
||||
// ---------------------------------------------------
|
||||
// 2️⃣ BODY & CONTENT TYPE ERMITTELN
|
||||
// ---------------------------------------------------
|
||||
let body: Buffer | Uint8Array | string
|
||||
let contentType = type || "application/octet-stream"
|
||||
|
||||
if (Buffer.isBuffer(attachment)) {
|
||||
// FALL 1: RAW BUFFER (von finishManualGeneration)
|
||||
body = attachment
|
||||
// ContentType wurde oben schon über 'type' Parameter gesetzt (z.B. application/pdf)
|
||||
} else if (typeof File !== "undefined" && attachment instanceof File) {
|
||||
// FALL 2: BROWSER FILE
|
||||
body = Buffer.from(await attachment.arrayBuffer())
|
||||
contentType = attachment.type || contentType
|
||||
} else if (attachment.content) {
|
||||
// FALL 3: MAILPARSER OBJECT
|
||||
body = attachment.content
|
||||
contentType = attachment.contentType || contentType
|
||||
} else {
|
||||
console.error("saveFile: Unknown attachment format")
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------
|
||||
// 3️⃣ S3 UPLOAD
|
||||
// ---------------------------------------------------
|
||||
const key = `${tenant}/filesbyid/${created.id}/${filename}`
|
||||
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
ContentLength: body.length // <--- WICHTIG: Behebt den AWS Fehler
|
||||
})
|
||||
)
|
||||
|
||||
// ---------------------------------------------------
|
||||
// 4️⃣ PATH IN DB SETZEN
|
||||
// ---------------------------------------------------
|
||||
await server.db
|
||||
.update(files)
|
||||
.set({ path: key })
|
||||
.where(eq(files.id, created.id))
|
||||
|
||||
console.log(`File saved: ${key}`)
|
||||
return { id: created.id, key }
|
||||
} catch (err) {
|
||||
console.error("saveFile error:", err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
174
backend/src/utils/functions.ts
Normal file
174
backend/src/utils/functions.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {FastifyInstance} from "fastify";
|
||||
// import { PNG } from 'pngjs'
|
||||
// import { ready as zplReady } from 'zpl-renderer-js'
|
||||
// import { Utils } from '@mmote/niimbluelib'
|
||||
// import { createCanvas } from 'canvas'
|
||||
// import bwipjs from 'bwip-js'
|
||||
// import Sharp from 'sharp'
|
||||
// import fs from 'fs'
|
||||
|
||||
import { tenants } from "../../db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
export const useNextNumberRangeNumber = async (
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
numberRange: string
|
||||
) => {
|
||||
// 1️⃣ Tenant laden
|
||||
const [tenant] = await server.db
|
||||
.select()
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant ${tenantId} not found`)
|
||||
}
|
||||
|
||||
const numberRanges = tenant.numberRanges || {}
|
||||
|
||||
if (!numberRanges[numberRange]) {
|
||||
throw new Error(`Number range '${numberRange}' not found`)
|
||||
}
|
||||
|
||||
const current = numberRanges[numberRange]
|
||||
|
||||
// 2️⃣ Used Number generieren
|
||||
const usedNumber =
|
||||
(current.prefix || "") +
|
||||
current.nextNumber +
|
||||
(current.suffix || "")
|
||||
|
||||
// 3️⃣ nextNumber erhöhen
|
||||
const updatedRanges = {
|
||||
// @ts-ignore
|
||||
...numberRanges,
|
||||
[numberRange]: {
|
||||
...current,
|
||||
nextNumber: current.nextNumber + 1
|
||||
}
|
||||
}
|
||||
|
||||
// 4️⃣ Tenant aktualisieren
|
||||
await server.db
|
||||
.update(tenants)
|
||||
.set({ numberRanges: updatedRanges })
|
||||
.where(eq(tenants.id, tenantId))
|
||||
|
||||
return { usedNumber }
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
export async function encodeBase64ToNiimbot(base64Png, printDirection = 'top') {
|
||||
// 1️⃣ PNG dekodieren
|
||||
const buffer = Buffer.from(base64Png, 'base64')
|
||||
const png = PNG.sync.read(buffer) // liefert {width, height, data: Uint8Array(RGBA)}
|
||||
|
||||
const { width, height, data } = png
|
||||
console.log(width, height, data)
|
||||
const cols = printDirection === 'left' ? height : width
|
||||
const rows = printDirection === 'left' ? width : height
|
||||
const rowsData = []
|
||||
|
||||
console.log(cols)
|
||||
|
||||
if (cols % 8 !== 0) throw new Error('Column count must be multiple of 8')
|
||||
|
||||
// 2️⃣ Zeilenweise durchgehen und Bits bilden
|
||||
for (let row = 0; row < rows; row++) {
|
||||
let isVoid = true
|
||||
let blackPixelsCount = 0
|
||||
const rowData = new Uint8Array(cols / 8)
|
||||
|
||||
for (let colOct = 0; colOct < cols / 8; colOct++) {
|
||||
let pixelsOctet = 0
|
||||
for (let colBit = 0; colBit < 8; colBit++) {
|
||||
const x = printDirection === 'left' ? row : colOct * 8 + colBit
|
||||
const y = printDirection === 'left' ? height - 1 - (colOct * 8 + colBit) : row
|
||||
const idx = (y * width + x) * 4
|
||||
const lum = 0.299 * data[idx] + 0.587 * data[idx + 1] + 0.114 * data[idx + 2]
|
||||
const isBlack = lum < 128
|
||||
if (isBlack) {
|
||||
pixelsOctet |= 1 << (7 - colBit)
|
||||
isVoid = false
|
||||
blackPixelsCount++
|
||||
}
|
||||
}
|
||||
rowData[colOct] = pixelsOctet
|
||||
}
|
||||
|
||||
const newPart = {
|
||||
dataType: isVoid ? 'void' : 'pixels',
|
||||
rowNumber: row,
|
||||
repeat: 1,
|
||||
rowData: isVoid ? undefined : rowData,
|
||||
blackPixelsCount,
|
||||
}
|
||||
|
||||
if (rowsData.length === 0) {
|
||||
rowsData.push(newPart)
|
||||
} else {
|
||||
const last = rowsData[rowsData.length - 1]
|
||||
let same = newPart.dataType === last.dataType
|
||||
if (same && newPart.dataType === 'pixels') {
|
||||
same = Utils.u8ArraysEqual(newPart.rowData, last.rowData)
|
||||
}
|
||||
if (same) last.repeat++
|
||||
else rowsData.push(newPart)
|
||||
if (row % 200 === 199) {
|
||||
rowsData.push({
|
||||
dataType: 'check',
|
||||
rowNumber: row,
|
||||
repeat: 0,
|
||||
rowData: undefined,
|
||||
blackPixelsCount: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cols, rows, rowsData }
|
||||
}
|
||||
|
||||
export async function generateLabel(context,width,height) {
|
||||
// Canvas für Hintergrund & Text
|
||||
const canvas = createCanvas(width, height)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// Hintergrund weiß
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
// Überschrift
|
||||
ctx.fillStyle = '#000000'
|
||||
ctx.font = '32px Arial'
|
||||
ctx.fillText(context.text, 20, 40)
|
||||
|
||||
// 3) DataMatrix
|
||||
const dataMatrixPng = await bwipjs.toBuffer({
|
||||
bcid: 'datamatrix',
|
||||
text: context.datamatrix,
|
||||
scale: 6,
|
||||
})
|
||||
|
||||
// Basisbild aus Canvas
|
||||
const base = await Sharp(canvas.toBuffer())
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
// Alles zusammen compositen
|
||||
const final = await Sharp(base)
|
||||
.composite([
|
||||
{ input: dataMatrixPng, top: 60, left: 20 },
|
||||
])
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
fs.writeFileSync('label.png', final)
|
||||
|
||||
// Optional: Base64 zurückgeben (z.B. für API)
|
||||
const base64 = final.toString('base64')
|
||||
|
||||
return base64
|
||||
}*/
|
||||
204
backend/src/utils/gpt.ts
Normal file
204
backend/src/utils/gpt.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import dayjs from "dayjs";
|
||||
import axios from "axios";
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { Blob } from "buffer";
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
import { s3 } from "./s3";
|
||||
import { secrets } from "./secrets";
|
||||
|
||||
// Drizzle schema
|
||||
import { vendors, accounts } from "../../db/schema";
|
||||
import {eq} from "drizzle-orm";
|
||||
|
||||
let openai: OpenAI | null = null;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// INITIALIZE OPENAI
|
||||
// ---------------------------------------------------------
|
||||
export const initOpenAi = async () => {
|
||||
openai = new OpenAI({
|
||||
apiKey: secrets.OPENAI_API_KEY,
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// STREAM → BUFFER
|
||||
// ---------------------------------------------------------
|
||||
async function streamToBuffer(stream: any): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// GPT RESPONSE FORMAT (Zod Schema)
|
||||
// ---------------------------------------------------------
|
||||
const InstructionFormat = z.object({
|
||||
invoice_number: z.string(),
|
||||
invoice_date: z.string(),
|
||||
invoice_duedate: z.string(),
|
||||
invoice_type: z.string(),
|
||||
delivery_type: z.string(),
|
||||
delivery_note_number: z.string(),
|
||||
reference: z.string(),
|
||||
issuer: z.object({
|
||||
id: z.number().nullable().optional(),
|
||||
name: z.string(),
|
||||
address: z.string(),
|
||||
phone: z.string(),
|
||||
email: z.string(),
|
||||
bank: z.string(),
|
||||
bic: z.string(),
|
||||
iban: z.string(),
|
||||
}),
|
||||
recipient: z.object({
|
||||
name: z.string(),
|
||||
address: z.string(),
|
||||
phone: z.string(),
|
||||
email: z.string(),
|
||||
}),
|
||||
invoice_items: z.array(
|
||||
z.object({
|
||||
description: z.string(),
|
||||
unit: z.string(),
|
||||
quantity: z.number(),
|
||||
total: z.number(),
|
||||
total_without_tax: z.number(),
|
||||
tax_rate: z.number(),
|
||||
ean: z.number().nullable().optional(),
|
||||
article_number: z.number().nullable().optional(),
|
||||
account_number: z.number().nullable().optional(),
|
||||
account_id: z.number().nullable().optional(),
|
||||
})
|
||||
),
|
||||
subtotal: z.number(),
|
||||
tax_rate: z.number(),
|
||||
tax: z.number(),
|
||||
total: z.number(),
|
||||
terms: z.string(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// MAIN FUNCTION – REPLACES SUPABASE VERSION
|
||||
// ---------------------------------------------------------
|
||||
export const getInvoiceDataFromGPT = async function (
|
||||
server: FastifyInstance,
|
||||
file: any,
|
||||
tenantId: number
|
||||
) {
|
||||
await initOpenAi();
|
||||
|
||||
if (!openai) {
|
||||
throw new Error("OpenAI not initialized. Call initOpenAi() first.");
|
||||
}
|
||||
|
||||
console.log(`📄 Reading invoice file ${file.id}`);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 1) DOWNLOAD PDF FROM S3
|
||||
// ---------------------------------------------------------
|
||||
let fileData: Buffer;
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: file.path,
|
||||
});
|
||||
|
||||
const response: any = await s3.send(command);
|
||||
fileData = await streamToBuffer(response.Body);
|
||||
} catch (err) {
|
||||
console.log(`❌ S3 Download failed for file ${file.id}`, err);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only process PDFs
|
||||
if (!file.path.toLowerCase().endsWith(".pdf")) {
|
||||
server.log.warn(`Skipping non-PDF file ${file.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileBlob = new Blob([fileData], { type: "application/pdf" });
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2) SEND FILE TO PDF → TEXT API
|
||||
// ---------------------------------------------------------
|
||||
const form = new FormData();
|
||||
form.append("fileInput", fileBlob, file.path.split("/").pop());
|
||||
form.append("outputFormat", "txt");
|
||||
|
||||
let extractedText: string;
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
"http://23.88.52.85:8080/api/v1/convert/pdf/text",
|
||||
form,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
Authorization: `Bearer ${secrets.STIRLING_API_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
extractedText = res.data;
|
||||
} catch (err) {
|
||||
console.log("❌ PDF OCR API failed", err);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3) LOAD VENDORS + ACCOUNTS (DRIZZLE)
|
||||
// ---------------------------------------------------------
|
||||
const vendorList = await server.db
|
||||
.select({ id: vendors.id, name: vendors.name })
|
||||
.from(vendors)
|
||||
.where(eq(vendors.tenant,tenantId));
|
||||
|
||||
const accountList = await server.db
|
||||
.select({
|
||||
id: accounts.id,
|
||||
label: accounts.label,
|
||||
number: accounts.number,
|
||||
})
|
||||
.from(accounts);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4) GPT ANALYSIS
|
||||
// ---------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
const completion = await openai.chat.completions.parse({
|
||||
model: "gpt-4o",
|
||||
store: true,
|
||||
response_format: zodResponseFormat(InstructionFormat as any, "instruction"),
|
||||
messages: [
|
||||
{ role: "user", content: extractedText },
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"You extract structured invoice data.\n\n" +
|
||||
`VENDORS: ${JSON.stringify(vendorList)}\n` +
|
||||
`ACCOUNTS: ${JSON.stringify(accountList)}\n\n` +
|
||||
"Match issuer by name to vendor.id.\n" +
|
||||
"Match invoice items to account id based on label/number.\n" +
|
||||
"Convert dates to YYYY-MM-DD.\n" +
|
||||
"Keep invoice items in original order.\n",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const parsed = completion.choices[0].message.parsed;
|
||||
|
||||
console.log(`🧾 Extracted invoice data for file ${file.id}`);
|
||||
|
||||
return parsed;
|
||||
};
|
||||
106
backend/src/utils/helpers.ts
Normal file
106
backend/src/utils/helpers.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// 🔧 Hilfsfunktionen
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { eq, ilike, and } from "drizzle-orm"
|
||||
|
||||
import { contacts, customers } from "../../db/schema"
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Extract Domain
|
||||
// -------------------------------------------------------------
|
||||
export function extractDomain(email: string) {
|
||||
if (!email) return null
|
||||
const parts = email.split("@")
|
||||
return parts.length === 2 ? parts[1].toLowerCase() : null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Kunde oder Kontakt anhand E-Mail oder Domain finden
|
||||
// -------------------------------------------------------------
|
||||
export async function findCustomerOrContactByEmailOrDomain(
|
||||
server: FastifyInstance,
|
||||
fromMail: string,
|
||||
tenantId: number
|
||||
) {
|
||||
const sender = fromMail.toLowerCase()
|
||||
const senderDomain = extractDomain(sender)
|
||||
if (!senderDomain) return null
|
||||
|
||||
// 1️⃣ Direkter Match über Contacts (email)
|
||||
const contactMatch = await server.db
|
||||
.select({
|
||||
id: contacts.id,
|
||||
customer: contacts.customer,
|
||||
})
|
||||
.from(contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.email, sender),
|
||||
eq(contacts.tenant, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (contactMatch.length && contactMatch[0].customer) {
|
||||
return {
|
||||
customer: contactMatch[0].customer,
|
||||
contact: contactMatch[0].id,
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ Kunden anhand Domain vergleichen
|
||||
const allCustomers = await server.db
|
||||
.select({
|
||||
id: customers.id,
|
||||
infoData: customers.infoData,
|
||||
})
|
||||
.from(customers)
|
||||
.where(eq(customers.tenant, tenantId))
|
||||
|
||||
for (const c of allCustomers) {
|
||||
const info = c.infoData || {}
|
||||
|
||||
// @ts-ignore
|
||||
const email = info.email?.toLowerCase()
|
||||
//@ts-ignore
|
||||
const invoiceEmail = info.invoiceEmail?.toLowerCase()
|
||||
const emailDomain = extractDomain(email)
|
||||
const invoiceDomain = extractDomain(invoiceEmail)
|
||||
|
||||
if (
|
||||
sender === email ||
|
||||
sender === invoiceEmail ||
|
||||
senderDomain === emailDomain ||
|
||||
senderDomain === invoiceDomain
|
||||
) {
|
||||
return { customer: c.id, contact: null }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// getNestedValue (für Sortierung & Suche im Backend)
|
||||
// -------------------------------------------------------------
|
||||
export function getNestedValue(obj: any, path: string): any {
|
||||
return path
|
||||
.split(".")
|
||||
.reduce((acc, part) => (acc && acc[part] !== undefined ? acc[part] : undefined), obj)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// compareValues (Sortierung für paginated)
|
||||
// -------------------------------------------------------------
|
||||
export function compareValues(a: any, b: any): number {
|
||||
if (a === b) return 0
|
||||
if (a == null) return 1
|
||||
if (b == null) return -1
|
||||
|
||||
// String Compare
|
||||
if (typeof a === "string" && typeof b === "string") {
|
||||
return a.localeCompare(b)
|
||||
}
|
||||
|
||||
// Numerisch
|
||||
return a < b ? -1 : 1
|
||||
}
|
||||
70
backend/src/utils/history.ts
Normal file
70
backend/src/utils/history.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
|
||||
export async function insertHistoryItem(
|
||||
server: FastifyInstance,
|
||||
params: {
|
||||
tenant_id: number
|
||||
created_by: string | null
|
||||
entity: string
|
||||
entityId: string | number
|
||||
action: "created" | "updated" | "unchanged" | "deleted" | "archived"
|
||||
oldVal?: any
|
||||
newVal?: any
|
||||
text?: string
|
||||
}
|
||||
) {
|
||||
const textMap = {
|
||||
created: `Neuer Eintrag in ${params.entity} erstellt`,
|
||||
updated: `Eintrag in ${params.entity} geändert`,
|
||||
archived: `Eintrag in ${params.entity} archiviert`,
|
||||
deleted: `Eintrag in ${params.entity} gelöscht`
|
||||
}
|
||||
|
||||
const columnMap: Record<string, string> = {
|
||||
customers: "customer",
|
||||
vendors: "vendor",
|
||||
projects: "project",
|
||||
plants: "plant",
|
||||
contacts: "contact",
|
||||
inventoryitems: "inventoryitem",
|
||||
products: "product",
|
||||
profiles: "profile",
|
||||
absencerequests: "absencerequest",
|
||||
events: "event",
|
||||
tasks: "task",
|
||||
vehicles: "vehicle",
|
||||
costcentres: "costcentre",
|
||||
ownaccounts: "ownaccount",
|
||||
documentboxes: "documentbox",
|
||||
hourrates: "hourrate",
|
||||
services: "service",
|
||||
roles: "role",
|
||||
checks: "check",
|
||||
spaces: "space",
|
||||
trackingtrips: "trackingtrip",
|
||||
createddocuments: "createddocument",
|
||||
inventoryitemgroups: "inventoryitemgroup",
|
||||
bankstatements: "bankstatement"
|
||||
}
|
||||
|
||||
const fkColumn = columnMap[params.entity]
|
||||
if (!fkColumn) {
|
||||
server.log.warn(`Keine History-Spalte für Entity: ${params.entity}`)
|
||||
return
|
||||
}
|
||||
|
||||
const entry = {
|
||||
tenant: params.tenant_id,
|
||||
created_by: params.created_by,
|
||||
text: params.text || textMap[params.action],
|
||||
action: params.action,
|
||||
[fkColumn]: params.entityId,
|
||||
oldVal: params.oldVal ? JSON.stringify(params.oldVal) : null,
|
||||
newVal: params.newVal ? JSON.stringify(params.newVal) : null
|
||||
}
|
||||
|
||||
const { error } = await server.supabase.from("historyitems").insert([entry])
|
||||
if (error) { // @ts-ignore
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
37
backend/src/utils/mailer.ts
Normal file
37
backend/src/utils/mailer.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import nodemailer from "nodemailer"
|
||||
import {secrets} from "./secrets"
|
||||
|
||||
export let transporter = null
|
||||
export const initMailer = async () => {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: secrets.MAILER_SMTP_HOST,
|
||||
port: Number(secrets.MAILER_SMTP_PORT) || 587,
|
||||
secure: secrets.MAILER_SMTP_SSL === "true", // true für 465, false für andere Ports
|
||||
auth: {
|
||||
user: secrets.MAILER_SMTP_USER,
|
||||
pass: secrets.MAILER_SMTP_PASS,
|
||||
},
|
||||
})
|
||||
console.log("Mailer Initialized!")
|
||||
}
|
||||
|
||||
export async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string
|
||||
): Promise<{ success: boolean; info?: any; error?: any }> {
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: secrets.MAILER_FROM,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
|
||||
// Nodemailer liefert eine Info-Response zurück
|
||||
return { success: true, info }
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Mailversand:", err)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
}
|
||||
15
backend/src/utils/password.ts
Normal file
15
backend/src/utils/password.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import bcrypt from "bcrypt"
|
||||
|
||||
export function generateRandomPassword(length = 12): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"
|
||||
let password = ""
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return password
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 10
|
||||
return bcrypt.hash(password, saltRounds)
|
||||
}
|
||||
1126
backend/src/utils/pdf.ts
Normal file
1126
backend/src/utils/pdf.ts
Normal file
File diff suppressed because it is too large
Load Diff
174
backend/src/utils/resource.config.ts
Normal file
174
backend/src/utils/resource.config.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
accounts,
|
||||
bankaccounts,
|
||||
bankrequisitions,
|
||||
bankstatements,
|
||||
contacts,
|
||||
contracts,
|
||||
costcentres,
|
||||
createddocuments,
|
||||
customers,
|
||||
files,
|
||||
filetags,
|
||||
folders,
|
||||
hourrates,
|
||||
incominginvoices,
|
||||
inventoryitemgroups,
|
||||
inventoryitems,
|
||||
letterheads,
|
||||
ownaccounts,
|
||||
plants,
|
||||
productcategories,
|
||||
products,
|
||||
projects,
|
||||
projecttypes,
|
||||
serialExecutions,
|
||||
servicecategories,
|
||||
services,
|
||||
spaces,
|
||||
statementallocations,
|
||||
tasks,
|
||||
texttemplates,
|
||||
units,
|
||||
vehicles,
|
||||
vendors
|
||||
} from "../../db/schema";
|
||||
|
||||
export const resourceConfig = {
|
||||
projects: {
|
||||
searchColumns: ["name"],
|
||||
mtoLoad: ["customer","plant","contract","projecttype"],
|
||||
mtmLoad: ["tasks", "files","createddocuments"],
|
||||
table: projects,
|
||||
numberRangeHolder: "projectNumber"
|
||||
},
|
||||
customers: {
|
||||
searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"],
|
||||
mtmLoad: ["contacts","projects","plants","createddocuments","contracts"],
|
||||
table: customers,
|
||||
numberRangeHolder: "customerNumber",
|
||||
},
|
||||
contacts: {
|
||||
searchColumns: ["firstName", "lastName", "email", "phone", "notes"],
|
||||
table: contacts,
|
||||
mtoLoad: ["customer","vendor"]
|
||||
},
|
||||
contracts: {
|
||||
table: contracts,
|
||||
searchColumns: ["name", "notes", "contractNumber", "paymentType", "sepaRef", "bankingName"],
|
||||
numberRangeHolder: "contractNumber",
|
||||
},
|
||||
plants: {
|
||||
table: plants,
|
||||
mtoLoad: ["customer"],
|
||||
mtmLoad: ["projects","tasks","files"],
|
||||
},
|
||||
projecttypes: {
|
||||
table: projecttypes
|
||||
},
|
||||
vendors: {
|
||||
table: vendors,
|
||||
searchColumns: ["name","vendorNumber","notes","defaultPaymentType"],
|
||||
numberRangeHolder: "vendorNumber",
|
||||
},
|
||||
files: {
|
||||
table: files
|
||||
},
|
||||
folders: {
|
||||
table: folders
|
||||
},
|
||||
filetags: {
|
||||
table: filetags
|
||||
},
|
||||
inventoryitems: {
|
||||
table: inventoryitems,
|
||||
numberRangeHolder: "articleNumber",
|
||||
},
|
||||
inventoryitemgroups: {
|
||||
table: inventoryitemgroups
|
||||
},
|
||||
products: {
|
||||
table: products,
|
||||
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
|
||||
},
|
||||
productcategories: {
|
||||
table: productcategories
|
||||
},
|
||||
services: {
|
||||
table: services,
|
||||
mtoLoad: ["unit"],
|
||||
searchColumns: ["name","description"],
|
||||
},
|
||||
servicecategories: {
|
||||
table: servicecategories
|
||||
},
|
||||
units: {
|
||||
table: units,
|
||||
},
|
||||
vehicles: {
|
||||
table: vehicles,
|
||||
searchColumns: ["name","license_plate","vin","color"],
|
||||
},
|
||||
hourrates: {
|
||||
table: hourrates,
|
||||
searchColumns: ["name"],
|
||||
},
|
||||
spaces: {
|
||||
table: spaces,
|
||||
searchColumns: ["name","space_number","type","info_data"],
|
||||
numberRangeHolder: "spaceNumber",
|
||||
},
|
||||
ownaccounts: {
|
||||
table: ownaccounts,
|
||||
searchColumns: ["name","description","number"],
|
||||
},
|
||||
costcentres: {
|
||||
table: costcentres,
|
||||
searchColumns: ["name","number","description"],
|
||||
mtoLoad: ["vehicle","project","inventoryitem"],
|
||||
numberRangeHolder: "number",
|
||||
},
|
||||
tasks: {
|
||||
table: tasks,
|
||||
},
|
||||
letterheads: {
|
||||
table: letterheads,
|
||||
|
||||
},
|
||||
createddocuments: {
|
||||
table: createddocuments,
|
||||
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"],
|
||||
mtmLoad: ["statementallocations","files","createddocuments"],
|
||||
mtmListLoad: ["statementallocations"],
|
||||
},
|
||||
texttemplates: {
|
||||
table: texttemplates
|
||||
},
|
||||
incominginvoices: {
|
||||
table: incominginvoices,
|
||||
mtmLoad: ["statementallocations","files"],
|
||||
mtmListLoad: ["statementallocations"],
|
||||
mtoLoad: ["vendor"],
|
||||
},
|
||||
statementallocations: {
|
||||
table: statementallocations,
|
||||
mtoLoad: ["customer","vendor","incominginvoice","createddocument","ownaccount","bankstatement"]
|
||||
},
|
||||
accounts: {
|
||||
table: accounts,
|
||||
},
|
||||
bankstatements: {
|
||||
table: bankstatements,
|
||||
mtmListLoad: ["statementallocations"],
|
||||
mtmLoad: ["statementallocations"],
|
||||
},
|
||||
bankaccounts: {
|
||||
table: bankaccounts,
|
||||
},
|
||||
bankrequisitions: {
|
||||
table: bankrequisitions,
|
||||
},
|
||||
serialexecutions: {
|
||||
table: serialExecutions
|
||||
}
|
||||
}
|
||||
18
backend/src/utils/s3.ts
Normal file
18
backend/src/utils/s3.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||
import {secrets} from "./secrets";
|
||||
|
||||
|
||||
|
||||
export let s3 = null
|
||||
|
||||
export const initS3 = async () => {
|
||||
s3 = new S3Client({
|
||||
endpoint: secrets.S3_ENDPOINT, // z. B. http://localhost:9000 für MinIO
|
||||
region: secrets.S3_REGION,
|
||||
credentials: {
|
||||
accessKeyId: secrets.S3_ACCESS_KEY,
|
||||
secretAccessKey: secrets.S3_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true, // wichtig für MinIO
|
||||
})
|
||||
}
|
||||
63
backend/src/utils/secrets.ts
Normal file
63
backend/src/utils/secrets.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {InfisicalSDK} from "@infisical/sdk"
|
||||
|
||||
const client = new InfisicalSDK({
|
||||
siteUrl: "https://secrets.fedeo.io"
|
||||
})
|
||||
|
||||
|
||||
|
||||
export let secrets = {
|
||||
|
||||
} as {
|
||||
COOKIE_SECRET: string
|
||||
JWT_SECRET: string
|
||||
PORT: number
|
||||
HOST: string
|
||||
DATABASE_URL: string
|
||||
SUPABASE_URL: string
|
||||
SUPABASE_SERVICE_ROLE_KEY: string
|
||||
S3_BUCKET: string
|
||||
ENCRYPTION_KEY: string
|
||||
MAILER_SMTP_HOST: string
|
||||
MAILER_SMTP_PORT: number
|
||||
MAILER_SMTP_SSL: string
|
||||
MAILER_SMTP_USER: string
|
||||
MAILER_SMTP_PASS: string
|
||||
MAILER_FROM: string
|
||||
S3_ENDPOINT: string
|
||||
S3_REGION: string
|
||||
S3_ACCESS_KEY: string
|
||||
S3_SECRET_KEY: string
|
||||
M2M_API_KEY: string
|
||||
API_BASE_URL: string
|
||||
GOCARDLESS_BASE_URL: string
|
||||
GOCARDLESS_SECRET_ID: string
|
||||
GOCARDLESS_SECRET_KEY: string
|
||||
DOKUBOX_IMAP_HOST: string
|
||||
DOKUBOX_IMAP_PORT: number
|
||||
DOKUBOX_IMAP_SECURE: boolean
|
||||
DOKUBOX_IMAP_USER: string
|
||||
DOKUBOX_IMAP_PASSWORD: string
|
||||
OPENAI_API_KEY: string
|
||||
STIRLING_API_KEY: string
|
||||
}
|
||||
|
||||
export async function loadSecrets () {
|
||||
|
||||
await client.auth().universalAuth.login({
|
||||
clientId: process.env.INFISICAL_CLIENT_ID,
|
||||
clientSecret: process.env.INFISICAL_CLIENT_SECRET,
|
||||
});
|
||||
|
||||
const allSecrets = await client.secrets().listSecrets({
|
||||
environment: "dev", // stg, dev, prod, or custom environment slugs
|
||||
projectId: "39774094-2aaf-49fb-a213-d6b2c10f6144"
|
||||
});
|
||||
|
||||
allSecrets.secrets.forEach(secret => {
|
||||
secrets[secret.secretKey] = secret.secretValue
|
||||
})
|
||||
console.log("✅ Secrets aus Infisical geladen");
|
||||
console.log(Object.keys(secrets).length + " Stück")
|
||||
}
|
||||
|
||||
40
backend/src/utils/sort.ts
Normal file
40
backend/src/utils/sort.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Sortiert ein Array von Objekten anhand einer Spalte.
|
||||
*
|
||||
* @param data Array von Objekten
|
||||
* @param column Sortierspalte (Property-Name im Objekt)
|
||||
* @param ascending true = aufsteigend, false = absteigend
|
||||
*/
|
||||
export function sortData<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
column?: keyof T | null,
|
||||
ascending: boolean = true
|
||||
): T[] {
|
||||
if (!column) return data
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const valA = a[column]
|
||||
const valB = b[column]
|
||||
|
||||
// null/undefined nach hinten
|
||||
if (valA == null && valB != null) return 1
|
||||
if (valB == null && valA != null) return -1
|
||||
if (valA == null && valB == null) return 0
|
||||
|
||||
// Zahlenvergleich
|
||||
if (typeof valA === "number" && typeof valB === "number") {
|
||||
return ascending ? valA - valB : valB - valA
|
||||
}
|
||||
|
||||
// Datumsvergleich
|
||||
// @ts-ignore
|
||||
if (valA instanceof Date && valB instanceof Date) {
|
||||
return ascending ? valA.getTime() - valB.getTime() : valB.getTime() - valA.getTime()
|
||||
}
|
||||
|
||||
// Fallback: Stringvergleich
|
||||
return ascending
|
||||
? String(valA).localeCompare(String(valB))
|
||||
: String(valB).localeCompare(String(valA))
|
||||
})
|
||||
}
|
||||
51
backend/src/utils/stringRendering.ts
Normal file
51
backend/src/utils/stringRendering.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
|
||||
export const renderAsCurrency = (value: string | number,currencyString = "€") => {
|
||||
return `${Number(value).toFixed(2).replace(".",",")} ${currencyString}`
|
||||
}
|
||||
|
||||
export const splitStringBySpace = (input:string,maxSplitLength:number,removeLinebreaks = false) => {
|
||||
|
||||
if(removeLinebreaks) {
|
||||
input = input.replaceAll("\n","")
|
||||
}
|
||||
|
||||
let splitStrings: string[] = []
|
||||
|
||||
input.split("\n").forEach(string => {
|
||||
splitStrings.push(string)
|
||||
})
|
||||
|
||||
let returnSplitStrings: string[] = []
|
||||
|
||||
splitStrings.forEach(string => {
|
||||
let regex = / /gi, result, indices = [];
|
||||
while ( (result = regex.exec(string)) ) {
|
||||
indices.push(result.index);
|
||||
}
|
||||
|
||||
let lastIndex = 0
|
||||
|
||||
if(string.length > maxSplitLength) {
|
||||
let tempStrings = []
|
||||
|
||||
for (let i = maxSplitLength; i < string.length; i = i + maxSplitLength) {
|
||||
let nearestIndex = indices.length > 0 ? indices.reduce(function(prev, curr) {
|
||||
return (Math.abs(curr - i) < Math.abs(prev - i) ? curr : prev);
|
||||
}) : i
|
||||
|
||||
tempStrings.push(string.substring(lastIndex,nearestIndex))
|
||||
|
||||
lastIndex = indices.length > 0 ? nearestIndex + 1 : nearestIndex
|
||||
}
|
||||
|
||||
tempStrings.push(string.substring(lastIndex,input.length))
|
||||
|
||||
returnSplitStrings.push(...tempStrings)
|
||||
|
||||
} else {
|
||||
returnSplitStrings.push(string)
|
||||
}
|
||||
})
|
||||
|
||||
return returnSplitStrings
|
||||
}
|
||||
Reference in New Issue
Block a user