Added Backend

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

166
backend/src/index.ts Normal file
View 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();

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

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

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

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

View File

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

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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
private nl2br(s: string) {
return s.replace(/\n/g, '<br/>');
}
}

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

View 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: []
};
}

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

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

View File

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

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

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

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

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

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

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

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

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

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

View 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
View File

@@ -0,0 +1,117 @@
import { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import {
authTenantUsers,
authUsers,
tenants,
} from "../../db/schema";
export default async function adminRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// POST /admin/add-user-to-tenant
// -------------------------------------------------------------
server.post("/admin/add-user-to-tenant", async (req, reply) => {
try {
const body = req.body as {
user_id: string;
tenant_id: number;
role?: string;
mode?: "single" | "multi";
};
if (!body.user_id || !body.tenant_id) {
return reply.code(400).send({
error: "user_id and tenant_id required"
});
}
const mode = body.mode ?? "multi";
// ----------------------------
// SINGLE MODE → alte Verknüpfungen löschen
// ----------------------------
if (mode === "single") {
await server.db
.delete(authTenantUsers)
.where(eq(authTenantUsers.user_id, body.user_id));
}
// ----------------------------
// Neue Verknüpfung hinzufügen
// ----------------------------
await server.db
.insert(authTenantUsers)
// @ts-ignore
.values({
user_id: body.user_id,
tenantId: body.tenant_id,
role: body.role ?? "member",
});
return { success: true, mode };
} catch (err) {
console.error("ERROR /admin/add-user-to-tenant:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// GET /admin/user-tenants/:user_id
// -------------------------------------------------------------
server.get("/admin/user-tenants/:user_id", async (req, reply) => {
try {
const { user_id } = req.params as { user_id: string };
if (!user_id) {
return reply.code(400).send({ error: "user_id required" });
}
// ----------------------------
// 1) User existiert?
// ----------------------------
const [user] = await server.db
.select()
.from(authUsers)
.where(eq(authUsers.id, user_id))
.limit(1);
if (!user) {
return reply.code(400).send({ error: "faulty user_id presented" });
}
// ----------------------------
// 2) Tenants Join über auth_tenant_users
// ----------------------------
const tenantRecords = await server.db
.select({
id: tenants.id,
name: tenants.name,
short: tenants.short,
locked: tenants.locked,
numberRanges: tenants.numberRanges,
extraModules: tenants.extraModules,
})
.from(authTenantUsers)
.innerJoin(
tenants,
eq(authTenantUsers.tenant_id, tenants.id)
)
.where(eq(authTenantUsers.user_id, user_id));
return {
user_id,
tenants: tenantRecords,
};
} catch (err) {
console.error("ERROR /admin/user-tenants:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
}

View File

@@ -0,0 +1,96 @@
import { FastifyInstance } from "fastify"
import bcrypt from "bcrypt"
import { eq } from "drizzle-orm"
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
export default async function authRoutesAuthenticated(server: FastifyInstance) {
server.post("/auth/password/change", {
schema: {
tags: ["Auth"],
summary: "Change password (after login or forced reset)",
body: {
type: "object",
required: ["old_password", "new_password"],
properties: {
old_password: { type: "string" },
new_password: { type: "string" },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
},
},
},
},
}, async (req, reply) => {
try {
const { old_password, new_password } = req.body as {
old_password: string
new_password: string
}
const userId = req.user?.user_id
if (!userId) {
//@ts-ignore
return reply.code(401).send({ error: "Unauthorized" })
}
// -----------------------------------------------------
// 1) User laden
// -----------------------------------------------------
const [user] = await server.db
.select({
id: authUsers.id,
passwordHash: authUsers.passwordHash,
mustChangePassword: authUsers.must_change_password
})
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
if (!user) {
//@ts-ignore
return reply.code(404).send({ error: "User not found" })
}
// -----------------------------------------------------
// 2) Altes PW prüfen
// -----------------------------------------------------
const valid = await bcrypt.compare(old_password, user.passwordHash)
if (!valid) {
//@ts-ignore
return reply.code(401).send({ error: "Old password incorrect" })
}
// -----------------------------------------------------
// 3) Neues PW hashen
// -----------------------------------------------------
const newHash = await bcrypt.hash(new_password, 10)
// -----------------------------------------------------
// 4) Updaten
// -----------------------------------------------------
await server.db
.update(authUsers)
.set({
passwordHash: newHash,
must_change_password: false,
updatedAt: new Date(),
})
.where(eq(authUsers.id, userId))
return { success: true }
} catch (err) {
console.error("POST /auth/password/change ERROR:", err)
//@ts-ignore
return reply.code(500).send({ error: "Internal Server Error" })
}
})
}

View File

@@ -0,0 +1,224 @@
import { FastifyInstance } from "fastify";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { generateRandomPassword, hashPassword } from "../../utils/password";
import { sendMail } from "../../utils/mailer";
import { secrets } from "../../utils/secrets";
import { authUsers } from "../../../db/schema";
import { authTenantUsers } from "../../../db/schema";
import { tenants } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
export default async function authRoutes(server: FastifyInstance) {
// -----------------------------------------------------
// REGISTER
// -----------------------------------------------------
server.post("/auth/register", {
schema: {
tags: ["Auth"],
summary: "Register User",
body: {
type: "object",
required: ["email", "password"],
properties: {
email: { type: "string", format: "email" },
password: { type: "string" },
},
},
},
}, async (req, reply) => {
const body = req.body as { email: string; password: string };
const passwordHash = await bcrypt.hash(body.password, 10);
const [user] = await server.db
.insert(authUsers)
.values({
email: body.email.toLowerCase(),
passwordHash,
})
.returning({
id: authUsers.id,
email: authUsers.email,
});
return { user };
});
// -----------------------------------------------------
// LOGIN
// -----------------------------------------------------
server.post("/auth/login", {
schema: {
tags: ["Auth"],
summary: "Login User",
body: {
type: "object",
required: ["email", "password"],
properties: {
email: { type: "string", format: "email" },
password: { type: "string" },
},
},
},
}, async (req, reply) => {
const body = req.body as { email: string; password: string };
let user: any = null;
// -------------------------------
// SINGLE TENANT MODE
// -------------------------------
/* if (req.tenant) {
const tenantId = req.tenant.id;
const result = await server.db
.select({
user: authUsers,
})
.from(authUsers)
.innerJoin(
authTenantUsers,
eq(authTenantUsers.userId, authUsers.id)
)
.innerJoin(
tenants,
eq(authTenantUsers.tenantId, tenants.id)
)
.where(and(
eq(authUsers.email, body.email.toLowerCase()),
eq(authTenantUsers.tenantId, tenantId)
));
if (result.length === 0) {
return reply.code(401).send({ error: "Invalid credentials" });
}
user = result[0].user;
// -------------------------------
// MULTI TENANT MODE
// -------------------------------
} else {*/
const [found] = await server.db
.select()
.from(authUsers)
.where(eq(authUsers.email, body.email.toLowerCase()))
.limit(1);
if (!found) {
return reply.code(401).send({ error: "Invalid credentials" });
}
user = found;
/*}*/
// Passwort prüfen
const valid = await bcrypt.compare(body.password, user.passwordHash);
if (!valid) {
return reply.code(401).send({ error: "Invalid credentials" });
}
const token = jwt.sign(
{
user_id: user.id,
email: user.email,
tenant_id: req.tenant?.id ?? null,
},
secrets.JWT_SECRET!,
{ expiresIn: "6h" }
);
reply.setCookie("token", token, {
path: "/",
httpOnly: true,
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 3,
});
return { token };
});
// -----------------------------------------------------
// LOGOUT
// -----------------------------------------------------
server.post("/auth/logout", {
schema: {
tags: ["Auth"],
summary: "Logout User"
}
}, async (req, reply) => {
reply.clearCookie("token", {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
});
return { success: true };
});
// -----------------------------------------------------
// PASSWORD RESET
// -----------------------------------------------------
server.post("/auth/password/reset", {
schema: {
tags: ["Auth"],
summary: "Reset Password",
body: {
type: "object",
required: ["email"],
properties: {
email: { type: "string", format: "email" }
}
}
}
}, async (req, reply) => {
const { email } = req.body as { email: string };
const [user] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
})
.from(authUsers)
.where(eq(authUsers.email, email.toLowerCase()))
.limit(1);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
const plainPassword = generateRandomPassword();
const passwordHash = await hashPassword(plainPassword);
await server.db
.update(authUsers)
.set({
passwordHash,
// @ts-ignore
mustChangePassword: true,
})
.where(eq(authUsers.id, user.id));
await sendMail(
user.email,
"FEDEO | Dein neues Passwort",
`
<p>Hallo,</p>
<p>Dein Passwort wurde zurückgesetzt.</p>
<p><strong>Neues Passwort:</strong> ${plainPassword}</p>
<p>Bitte ändere es nach dem Login umgehend.</p>
`
);
return { success: true };
});
}

View File

@@ -0,0 +1,140 @@
import { FastifyInstance } from "fastify"
import {
authUsers,
authTenantUsers,
tenants,
authProfiles,
authUserRoles,
authRoles,
authRolePermissions,
} from "../../../db/schema"
import { eq, and, or, isNull } from "drizzle-orm"
export default async function meRoutes(server: FastifyInstance) {
server.get("/me", async (req, reply) => {
try {
const authUser = req.user
if (!authUser) {
return reply.code(401).send({ error: "Unauthorized" })
}
const userId = authUser.user_id
const activeTenantId = authUser.tenant_id
// ----------------------------------------------------
// 1) USER LADEN
// ----------------------------------------------------
const userResult = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
created_at: authUsers.created_at,
must_change_password: authUsers.must_change_password,
})
.from(authUsers)
.where(eq(authUsers.id, userId))
.limit(1)
const user = userResult[0]
if (!user) {
return reply.code(401).send({ error: "User not found" })
}
// ----------------------------------------------------
// 2) TENANTS LADEN
// ----------------------------------------------------
const tenantRows = await server.db
.select({
id: tenants.id,
name: tenants.name,
short: tenants.short,
locked: tenants.locked,
extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo,
numberRanges: tenants.numberRanges,
dokuboxkey: tenants.dokuboxkey,
standardEmailForInvoices: tenants.standardEmailForInvoices,
standardPaymentDays: tenants.standardPaymentDays,
})
.from(authTenantUsers)
.innerJoin(tenants, eq(authTenantUsers.tenant_id, tenants.id))
.where(eq(authTenantUsers.user_id, userId))
const tenantList = tenantRows ?? []
// ----------------------------------------------------
// 3) ACTIVE TENANT
// ----------------------------------------------------
const activeTenant = activeTenantId
// ----------------------------------------------------
// 4) PROFIL LADEN
// ----------------------------------------------------
let profile = null
if (activeTenantId) {
const profileResult = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, userId),
eq(authProfiles.tenant_id, activeTenantId)
)
)
.limit(1)
profile = profileResult?.[0] ?? null
}
// ----------------------------------------------------
// 5) PERMISSIONS — RPC ERSETZT
// ----------------------------------------------------
const permissionRows =
(await server.db
.select({
permission: authRolePermissions.permission,
})
.from(authUserRoles)
.innerJoin(
authRoles,
and(
eq(authRoles.id, authUserRoles.role_id),
or(
isNull(authRoles.tenant_id), // globale Rolle
eq(authRoles.tenant_id, activeTenantId) // tenant-spezifische Rolle
)
)
)
.innerJoin(
authRolePermissions,
eq(authRolePermissions.role_id, authRoles.id)
)
.where(
and(
eq(authUserRoles.user_id, userId),
eq(authUserRoles.tenant_id, activeTenantId)
)
)) ?? []
const permissions = Array.from(
new Set(permissionRows.map((p) => p.permission))
)
// ----------------------------------------------------
// RESPONSE
// ----------------------------------------------------
return {
user,
tenants: tenantList,
activeTenant,
profile,
permissions,
}
} catch (err: any) {
console.error("ERROR in /me route:", err)
return reply.code(500).send({ error: "Internal server error" })
}
})
}

View File

@@ -0,0 +1,129 @@
import { FastifyInstance } from "fastify"
import { eq, and } from "drizzle-orm"
import {
authUsers,
authProfiles,
} from "../../../db/schema"
export default async function userRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// GET /user/:id
// -------------------------------------------------------------
server.get("/user/:id", async (req, reply) => {
try {
const authUser = req.user
const { id } = req.params as { id: string }
if (!authUser) {
return reply.code(401).send({ error: "Unauthorized" })
}
// 1⃣ User laden
const [user] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
created_at: authUsers.created_at,
must_change_password: authUsers.must_change_password,
})
.from(authUsers)
.where(eq(authUsers.id, id))
if (!user) {
return reply.code(404).send({ error: "User not found" })
}
// 2⃣ Profil im Tenant
let profile = null
if (authUser.tenant_id) {
const [profileRow] = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, id),
eq(authProfiles.tenant_id, authUser.tenant_id)
)
)
profile = profileRow || null
}
return { user, profile }
} catch (err: any) {
console.error("/user/:id ERROR", err)
return reply.code(500).send({ error: err.message || "Internal error" })
}
})
// -------------------------------------------------------------
// PUT /user/:id/profile
// -------------------------------------------------------------
server.put("/user/:id/profile", async (req, reply) => {
try {
const { id } = req.params as { id: string }
const { data } = req.body as { data?: Record<string, any> }
if (!req.user?.tenant_id) {
return reply.code(401).send({ error: "Unauthorized" })
}
if (!data || typeof data !== "object") {
return reply.code(400).send({ error: "data object required" })
}
// 1⃣ Profil für diesen Tenant laden (damit wir die ID kennen)
const [profile] = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.user_id, id),
eq(authProfiles.tenant_id, req.user.tenant_id)
)
)
if (!profile) {
return reply.code(404).send({ error: "Profile not found in tenant" })
}
// 2⃣ Timestamp-Felder normalisieren (falls welche drin sind)
const normalizeDate = (val: any) => {
if (!val) return null
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
const updateData: any = { ...data }
// bekannte Date-Felder prüfen
if (data.entry_date !== undefined)
updateData.entry_date = normalizeDate(data.entry_date)
if (data.birthday !== undefined)
updateData.birthday = normalizeDate(data.birthday)
if (data.created_at !== undefined)
updateData.created_at = normalizeDate(data.created_at)
updateData.updated_at = new Date()
// 3⃣ Update durchführen
const [updatedProfile] = await server.db
.update(authProfiles)
.set(updateData)
.where(eq(authProfiles.id, profile.id))
.returning()
return { profile: updatedProfile }
} catch (err: any) {
console.error("PUT /user/:id/profile ERROR", err)
return reply.code(500).send({ error: err.message || "Internal server error" })
}
})
}

View File

@@ -0,0 +1,236 @@
import { FastifyInstance } from "fastify"
import axios from "axios"
import dayjs from "dayjs"
import { secrets } from "../utils/secrets"
import { insertHistoryItem } from "../utils/history"
import {
bankrequisitions,
statementallocations,
} from "../../db/schema"
import {
eq,
and,
} from "drizzle-orm"
export default async function bankingRoutes(server: FastifyInstance) {
// ------------------------------------------------------------------
// 🔐 GoCardLess Token Handling
// ------------------------------------------------------------------
const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL
const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID
const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY
let tokenData: any = null
const getToken = async () => {
const res = await axios.post(`${goCardLessBaseUrl}/token/new/`, {
secret_id: goCardLessSecretId,
secret_key: goCardLessSecretKey,
})
tokenData = res.data
tokenData.created_at = new Date().toISOString()
server.log.info("GoCardless token refreshed.")
}
const checkToken = async () => {
if (!tokenData) return await getToken()
const expired = dayjs(tokenData.created_at)
.add(tokenData.access_expires, "seconds")
.isBefore(dayjs())
if (expired) {
server.log.info("Refreshing expired GoCardless token …")
await getToken()
}
}
// ------------------------------------------------------------------
// 🔗 Create GoCardless Banking Link
// ------------------------------------------------------------------
server.get("/banking/link/:institutionid", async (req, reply) => {
try {
await checkToken()
const { institutionid } = req.params as { institutionid: string }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const { data } = await axios.post(
`${goCardLessBaseUrl}/requisitions/`,
{
redirect: "https://app.fedeo.de/settings/banking",
institution_id: institutionid,
user_language: "de",
},
{
headers: { Authorization: `Bearer ${tokenData.access}` },
}
)
// DB: Requisition speichern
await server.db.insert(bankrequisitions).values({
id: data.id,
tenant: tenantId,
institutionId: institutionid,
status: data.status,
})
return reply.send({ link: data.link })
} catch (err: any) {
server.log.error(err?.response?.data || err)
return reply.code(500).send({ error: "Failed to generate link" })
}
})
// ------------------------------------------------------------------
// 🏦 Check Bank Institutions
// ------------------------------------------------------------------
server.get("/banking/institutions/:bic", async (req, reply) => {
try {
const { bic } = req.params as { bic: string }
if (!bic) return reply.code(400).send("BIC missing")
await checkToken()
const { data } = await axios.get(
`${goCardLessBaseUrl}/institutions/?country=de`,
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
)
const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase())
if (!bank) return reply.code(404).send("Bank not found")
return reply.send(bank)
} catch (err: any) {
server.log.error(err?.response?.data || err)
return reply.code(500).send("Failed to fetch institutions")
}
})
// ------------------------------------------------------------------
// 📄 Get Requisition Details
// ------------------------------------------------------------------
server.get("/banking/requisitions/:reqId", async (req, reply) => {
try {
const { reqId } = req.params as { reqId: string }
if (!reqId) return reply.code(400).send("Requisition ID missing")
await checkToken()
const { data } = await axios.get(
`${goCardLessBaseUrl}/requisitions/${reqId}`,
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
)
// Load account details
if (data.accounts) {
data.accounts = await Promise.all(
data.accounts.map(async (accId: string) => {
const { data: acc } = await axios.get(
`${goCardLessBaseUrl}/accounts/${accId}`,
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
)
return acc
})
)
}
return reply.send(data)
} catch (err: any) {
server.log.error(err?.response?.data || err)
return reply.code(500).send("Failed to fetch requisition details")
}
})
// ------------------------------------------------------------------
// 💰 Create Statement Allocation
// ------------------------------------------------------------------
server.post("/banking/statements", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { data: payload } = req.body as { data: any }
const inserted = await server.db.insert(statementallocations).values({
...payload,
tenant: req.user.tenant_id
}).returning()
const createdRecord = inserted[0]
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: createdRecord.id,
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: createdRecord,
text: "Buchung erstellt",
})
return reply.send(createdRecord)
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Failed to create statement" })
}
})
// ------------------------------------------------------------------
// 🗑 Delete Statement Allocation
// ------------------------------------------------------------------
server.delete("/banking/statements/:id", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const oldRecord = await server.db
.select()
.from(statementallocations)
.where(eq(statementallocations.id, id))
.limit(1)
const old = oldRecord[0]
if (!old) return reply.code(404).send({ error: "Record not found" })
await server.db
.delete(statementallocations)
.where(eq(statementallocations.id, id))
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: id,
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: old,
newVal: null,
text: "Buchung gelöscht",
})
return reply.send({ success: true })
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Failed to delete statement" })
}
})
}

View File

@@ -0,0 +1,98 @@
import { FastifyInstance } from "fastify";
import {and, desc, eq} from "drizzle-orm";
import {authProfiles, devices, stafftimeevents} from "../../../db/schema";
export default async function devicesRFIDRoutes(server: FastifyInstance) {
server.post(
"/rfid/createevent/:terminal_id",
async (req, reply) => {
try {
const {rfid_id} = req.body as {rfid_id: string};
const {terminal_id} = req.params as {terminal_id: string};
if(!rfid_id ||!terminal_id) {
console.log(`Missing Params`);
return reply.code(400).send(`Missing Params`)
}
const device = await server.db
.select()
.from(devices)
.where(
eq(devices.externalId, terminal_id)
)
.limit(1)
.then(rows => rows[0]);
if(!device) {
console.log(`Device ${terminal_id} not found`);
return reply.code(400).send(`Device ${terminal_id} not found`)
}
const profile = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.tenant_id, device.tenant),
eq(authProfiles.token_id, rfid_id)
)
)
.limit(1)
.then(rows => rows[0]);
if(!profile) {
console.log(`Profile for Token ${rfid_id} not found`);
return reply.code(400).send(`Profile for Token ${rfid_id} not found`)
}
const lastEvent = await server.db
.select()
.from(stafftimeevents)
.where(
eq(stafftimeevents.user_id, profile.user_id)
)
.orderBy(desc(stafftimeevents.eventtime)) // <-- Sortierung: Neuestes zuerst
.limit(1)
.then(rows => rows[0]);
console.log(lastEvent)
const dataToInsert = {
tenant_id: device.tenant,
user_id: profile.user_id,
actortype: "system",
eventtime: new Date(),
eventtype: lastEvent.eventtype === "work_start" ? "work_end" : "work_start",
source: "WEB"
}
console.log(dataToInsert)
const [created] = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(dataToInsert)
.returning()
return created
} catch (err: any) {
console.error(err)
return reply.code(400).send({ error: err.message })
}
console.log(req.body)
return
}
);
}

View File

@@ -0,0 +1,262 @@
import nodemailer from "nodemailer"
import { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { sendMailAsUser } from "../utils/emailengine"
import { encrypt, decrypt } from "../utils/crypt"
import { userCredentials } from "../../db/schema"
// Pfad ggf. anpassen
// @ts-ignore
import MailComposer from "nodemailer/lib/mail-composer/index.js"
import { ImapFlow } from "imapflow"
export default async function emailAsUserRoutes(server: FastifyInstance) {
// ======================================================================
// CREATE OR UPDATE EMAIL ACCOUNT
// ======================================================================
server.post("/email/accounts/:id?", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id?: string }
const body = req.body as {
email: string
password: string
smtp_host: string
smtp_port: number
smtp_ssl: boolean
imap_host: string
imap_port: number
imap_ssl: boolean
}
// -----------------------------
// UPDATE EXISTING
// -----------------------------
if (id) {
const saveData = {
emailEncrypted: body.email ? encrypt(body.email) : undefined,
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
smtpHostEncrypted: body.smtp_host ? encrypt(body.smtp_host) : undefined,
smtpPort: body.smtp_port,
smtpSsl: body.smtp_ssl,
imapHostEncrypted: body.imap_host ? encrypt(body.imap_host) : undefined,
imapPort: body.imap_port,
imapSsl: body.imap_ssl,
}
await server.db
.update(userCredentials)
//@ts-ignore
.set(saveData)
.where(eq(userCredentials.id, id))
return reply.send({ success: true })
}
// -----------------------------
// CREATE NEW
// -----------------------------
const insertData = {
userId: req.user.user_id,
tenantId: req.user.tenant_id,
type: "mail",
emailEncrypted: encrypt(body.email),
passwordEncrypted: encrypt(body.password),
smtpHostEncrypted: encrypt(body.smtp_host),
smtpPort: body.smtp_port,
smtpSsl: body.smtp_ssl,
imapHostEncrypted: encrypt(body.imap_host),
imapPort: body.imap_port,
imapSsl: body.imap_ssl,
}
//@ts-ignore
await server.db.insert(userCredentials).values(insertData)
return reply.send({ success: true })
} catch (err) {
console.error("POST /email/accounts error:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// ======================================================================
// GET SINGLE OR ALL ACCOUNTS
// ======================================================================
server.get("/email/accounts/:id?", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id?: string }
// ============================================================
// LOAD SINGLE ACCOUNT
// ============================================================
if (id) {
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.id, id))
const row = rows[0]
if (!row) return reply.code(404).send({ error: "Not found" })
const returnData: any = {}
Object.entries(row).forEach(([key, val]) => {
if (key.endsWith("Encrypted")) {
const cleanKey = key.replace("Encrypted", "")
// @ts-ignore
returnData[cleanKey] = decrypt(val as string)
} else {
returnData[key] = val
}
})
return reply.send(returnData)
}
// ============================================================
// LOAD ALL ACCOUNTS FOR TENANT
// ============================================================
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.tenantId, req.user.tenant_id))
const accounts = rows.map(row => {
const temp: any = {}
console.log(row)
Object.entries(row).forEach(([key, val]) => {
console.log(key,val)
if (key.endsWith("Encrypted") && val) {
// @ts-ignore
temp[key.replace("Encrypted", "")] = decrypt(val)
} else {
temp[key] = val
}
})
return temp
})
return reply.send(accounts)
} catch (err) {
console.error("GET /email/accounts error:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// ======================================================================
// SEND EMAIL + SAVE IN IMAP SENT FOLDER
// ======================================================================
server.post("/email/send", async (req, reply) => {
try {
const body = req.body as {
to: string
cc?: string
bcc?: string
subject?: string
text?: string
html?: string
attachments?: any
account: string
}
// Fetch email credentials
const rows = await server.db
.select()
.from(userCredentials)
.where(eq(userCredentials.id, body.account))
const row = rows[0]
if (!row) return reply.code(404).send({ error: "Account not found" })
const accountData: any = {}
Object.entries(row).forEach(([key, val]) => {
if (key.endsWith("Encrypted") && val) {
// @ts-ignore
accountData[key.replace("Encrypted", "")] = decrypt(val as string)
} else {
accountData[key] = val
}
})
// -------------------------
// SEND EMAIL VIA SMTP
// -------------------------
const transporter = nodemailer.createTransport({
host: accountData.smtpHost,
port: accountData.smtpPort,
secure: accountData.smtpSsl,
auth: {
user: accountData.email,
pass: accountData.password,
},
})
const message = {
from: accountData.email,
to: body.to,
cc: body.cc,
bcc: body.bcc,
subject: body.subject,
html: body.html,
text: body.text,
attachments: body.attachments,
}
const info = await transporter.sendMail(message)
// -------------------------
// SAVE TO IMAP SENT FOLDER
// -------------------------
const imap = new ImapFlow({
host: accountData.imapHost,
port: accountData.imapPort,
secure: accountData.imapSsl,
auth: {
user: accountData.email,
pass: accountData.password,
},
})
await imap.connect()
const mail = new MailComposer(message)
const raw = await mail.compile().build()
for await (const mailbox of await imap.list()) {
if (mailbox.specialUse === "\\Sent") {
await imap.mailboxOpen(mailbox.path)
await imap.append(mailbox.path, raw, ["\\Seen"])
await imap.logout()
}
}
return reply.send({ success: true })
} catch (err) {
console.error("POST /email/send error:", err)
return reply.code(500).send({ error: "Failed to send email" })
}
})
}

View File

@@ -0,0 +1,128 @@
import { FastifyInstance } from "fastify";
import jwt from "jsonwebtoken";
import {insertHistoryItem} from "../utils/history";
import {buildExportZip} from "../utils/export/datev";
import {s3} from "../utils/s3";
import {GetObjectCommand, PutObjectCommand} from "@aws-sdk/client-s3"
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
import dayjs from "dayjs";
import {randomUUID} from "node:crypto";
import {secrets} from "../utils/secrets";
import {createSEPAExport} from "../utils/export/sepa";
const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDate,beraternr,mandantennr) => {
console.log(startDate,endDate,beraternr,mandantennr)
// 1) ZIP erzeugen
const buffer = await buildExportZip(server,req.user.tenant_id, startDate, endDate, beraternr, mandantennr)
console.log("ZIP created")
console.log(buffer)
// 2) Dateiname & Key festlegen
const fileKey = `${req.user.tenant_id}/exports/Export_${dayjs(startDate).format("YYYY-MM-DD")}_${dayjs(endDate).format("YYYY-MM-DD")}_${randomUUID()}.zip`
console.log(fileKey)
// 3) In S3 hochladen
await s3.send(
new PutObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: fileKey,
Body: buffer,
ContentType: "application/zip",
})
)
// 4) Presigned URL erzeugen (24h gültig)
const url = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: fileKey,
}),
{ expiresIn: 60 * 60 * 24 }
)
console.log(url)
// 5) In Supabase-DB speichern
const { data, error } = await server.supabase
.from("exports")
.insert([
{
tenant_id: req.user.tenant_id,
start_date: startDate,
end_date: endDate,
valid_until: dayjs().add(24,"hours").toISOString(),
file_path: fileKey,
url: url,
created_at: new Date().toISOString(),
},
])
.select()
.single()
console.log(data)
console.log(error)
}
export default async function exportRoutes(server: FastifyInstance) {
//Export DATEV
server.post("/exports/datev", async (req, reply) => {
const { start_date, end_date, beraternr, mandantennr } = req.body as {
start_date: string
end_date: string
beraternr: string
mandantennr: string
}
reply.send({success:true})
setImmediate(async () => {
try {
await createDatevExport(server,req,start_date,end_date,beraternr,mandantennr)
console.log("Job done ✅")
} catch (err) {
console.error("Job failed ❌", err)
}
})
})
server.post("/exports/sepa", async (req, reply) => {
const { idsToExport } = req.body as {
idsToExport: Array<number>
}
reply.send({success:true})
setImmediate(async () => {
try {
await createSEPAExport(server, idsToExport, req.user.tenant_id)
console.log("Job done ✅")
} catch (err) {
console.error("Job failed ❌", err)
}
})
})
//List Exports Available for Download
server.get("/exports", async (req,reply) => {
const {data,error} = await server.supabase.from("exports").select().eq("tenant_id",req.user.tenant_id)
console.log(data,error)
reply.send(data)
})
}

293
backend/src/routes/files.ts Normal file
View File

@@ -0,0 +1,293 @@
import { FastifyInstance } from "fastify"
import multipart from "@fastify/multipart"
import { s3 } from "../utils/s3"
import {
GetObjectCommand,
PutObjectCommand
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import archiver from "archiver"
import { secrets } from "../utils/secrets"
import { eq, inArray } from "drizzle-orm"
import {
files,
createddocuments,
customers
} from "../../db/schema"
export default async function fileRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// MULTIPART INIT
// -------------------------------------------------------------
await server.register(multipart, {
limits: { fileSize: 20 * 1024 * 1024 } // 20 MB
})
// -------------------------------------------------------------
// UPLOAD FILE
// -------------------------------------------------------------
server.post("/files/upload", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data: any = await req.file()
if (!data?.file) return reply.code(400).send({ error: "No file uploaded" })
const fileBuffer = await data.toBuffer()
const meta = data.fields?.meta?.value ? JSON.parse(data.fields.meta.value) : {}
// 1⃣ DB-Eintrag erzeugen
const inserted = await server.db
.insert(files)
.values({ tenant: tenantId })
.returning()
const created = inserted[0]
if (!created) throw new Error("Could not create DB entry")
// 2⃣ Datei in S3 speichern
const fileKey = `${tenantId}/filesbyid/${created.id}/${data.filename}`
await s3.send(new PutObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: fileKey,
Body: fileBuffer,
ContentType: data.mimetype
}))
// 3⃣ DB updaten: meta + path
await server.db
.update(files)
.set({
...meta,
path: fileKey
})
.where(eq(files.id, created.id))
return {
id: created.id,
filename: data.filename,
path: fileKey
}
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Upload failed" })
}
})
// -------------------------------------------------------------
// GET FILE OR LIST FILES
// -------------------------------------------------------------
server.get("/files/:id?", async (req, reply) => {
try {
const { id } = req.params as { id?: string }
// 🔹 EINZELNE DATEI
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
if (!file) return reply.code(404).send({ error: "Not found" })
return file
}
// 🔹 ALLE DATEIEN DES TENANTS (mit createddocument + customer)
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const list = await server.db
//@ts-ignore
.select({
...files,
createddocument: createddocuments,
customer: customers
})
.from(files)
.leftJoin(
createddocuments,
eq(files.createddocument, createddocuments.id)
)
.leftJoin(
customers,
eq(createddocuments.customer, customers.id)
)
.where(eq(files.tenant, tenantId))
return { files: list }
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Could not load files" })
}
})
// -------------------------------------------------------------
// DOWNLOAD (SINGLE OR MULTI ZIP)
// -------------------------------------------------------------
server.post("/files/download/:id?", async (req, reply) => {
try {
const { id } = req.params as { id?: string }
//@ts-ignore
const ids = req.body?.ids || []
// -------------------------------------------------
// 1⃣ SINGLE DOWNLOAD
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
if (!file) return reply.code(404).send({ error: "File not found" })
const command = new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: file.path!
})
const { Body, ContentType } = await s3.send(command)
const chunks: any[] = []
for await (const chunk of Body as any) chunks.push(chunk)
const buffer = Buffer.concat(chunks)
reply.header("Content-Type", ContentType || "application/octet-stream")
reply.header("Content-Disposition", `attachment; filename="${file.path?.split("/").pop()}"`)
return reply.send(buffer)
}
// -------------------------------------------------
// 2⃣ MULTI DOWNLOAD → ZIP
// -------------------------------------------------
if (Array.isArray(ids) && ids.length > 0) {
const rows = await server.db
.select()
.from(files)
.where(inArray(files.id, ids))
if (!rows.length) return reply.code(404).send({ error: "Files not found" })
reply.header("Content-Type", "application/zip")
reply.header("Content-Disposition", `attachment; filename="dateien.zip"`)
const archive = archiver("zip", { zlib: { level: 9 } })
for (const entry of rows) {
const cmd = new GetObjectCommand({
Bucket: secrets.S3_BUCKET,
Key: entry.path!
})
const { Body } = await s3.send(cmd)
archive.append(Body as any, {
name: entry.path?.split("/").pop() || entry.id
})
}
await archive.finalize()
return reply.send(archive)
}
return reply.code(400).send({ error: "No id or ids provided" })
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Download failed" })
}
})
// -------------------------------------------------------------
// GENERATE PRESIGNED URL(S)
// -------------------------------------------------------------
server.post("/files/presigned/:id?", async (req, reply) => {
try {
const { id } = req.params as { id?: string }
const { ids } = req.body as { ids?: string[] }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
// -------------------------------------------------
// SINGLE FILE PRESIGNED URL
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
if (!file) return reply.code(404).send({ error: "Not found" })
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
{ expiresIn: 900 }
)
return { ...file, url }
} else {
// -------------------------------------------------
// MULTIPLE PRESIGNED URLs
// -------------------------------------------------
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return reply.code(400).send({ error: "No ids provided" })
}
const rows = await server.db
.select()
.from(files)
.where(eq(files.tenant, tenantId))
const selected = rows.filter(f => ids.includes(f.id) && f.path)
console.log(selected)
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: selected[0].path! }),
{ expiresIn: 900 }
)
console.log(url)
console.log(selected.filter(f => !f.path))
const output = await Promise.all(
selected.map(async (file) => {
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! }),
{ expiresIn: 900 }
)
return { ...file, url }
})
)
return { files: output }
}
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Could not create presigned URLs" })
}
})
}

View File

@@ -0,0 +1,222 @@
import { FastifyInstance } from "fastify";
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
//import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import dayjs from "dayjs";
//import { ready as zplReady } from 'zpl-renderer-js'
//import { renderZPL } from "zpl-image";
import customParseFormat from "dayjs/plugin/customParseFormat.js";
import isoWeek from "dayjs/plugin/isoWeek.js";
import isBetween from "dayjs/plugin/isBetween.js";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"
import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
import duration from "dayjs/plugin/duration.js";
import timezone from "dayjs/plugin/timezone.js";
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
import {citys} from "../../db/schema";
import {eq} from "drizzle-orm";
import {useNextNumberRangeNumber} from "../utils/functions";
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
dayjs.extend(customParseFormat)
dayjs.extend(isoWeek)
dayjs.extend(isBetween)
dayjs.extend(isSameOrAfter)
dayjs.extend(isSameOrBefore)
dayjs.extend(duration)
dayjs.extend(timezone)
export default async function functionRoutes(server: FastifyInstance) {
server.post("/functions/pdf/:type", async (req, reply) => {
const body = req.body as {
data: any
backgroundPath?: string
}
const {type} = req.params as {type:string}
try {
let pdf = null
if(type === "createdDocument") {
pdf = await createInvoicePDF(
server,
"base64",
body.data,
body.backgroundPath
)
} else if(type === "timesheet") {
pdf = await createTimeSheetPDF(
server,
"base64",
body.data,
body.backgroundPath
)
}
return pdf // Fastify wandelt automatisch in JSON
} catch (err) {
console.log(err)
reply.code(500).send({ error: "Failed to create PDF" })
}
})
server.get("/functions/usenextnumber/:numberrange", async (req, reply) => {
const { numberrange } = req.params as { numberrange: string };
const tenant = (req as any).user.tenant_id
try {
const result = await useNextNumberRangeNumber(server,tenant, numberrange)
reply.send(result) // JSON automatisch
} catch (err) {
req.log.error(err)
reply.code(500).send({ error: "Failed to generate next number" })
}
})
/**
* @route GET /functions/workingtimeevaluation/:user_id
* @query start_date=YYYY-MM-DD
* @query end_date=YYYY-MM-DD
*/
server.get("/functions/timeevaluation/:user_id", async (req, reply) => {
const { user_id } = req.params as { user_id: string }
const { start_date, end_date } = req.query as { start_date: string; end_date: string }
const { tenant_id } = req.user
// 🔒 Sicherheitscheck: andere User nur bei Berechtigung
if (user_id !== req.user.user_id && !req.hasPermission("staff.time.read_all")) {
return reply.code(403).send({ error: "Not allowed to view other users." })
}
try {
const result = await generateTimesEvaluation(server, user_id, tenant_id, start_date, end_date)
reply.send(result)
} catch (error) {
console.error(error)
reply.code(500).send({ error: error.message })
}
})
server.get('/functions/check-zip/:zip', async (req, reply) => {
const { zip } = req.params as { zip: string }
if (!zip) {
return reply.code(400).send({ error: 'ZIP is required' })
}
try {
//@ts-ignore
const data = await server.db.select().from(citys).where(eq(citys.zip,zip))
/*const { data, error } = await server.supabase
.from('citys')
.select()
.eq('zip', zip)
.maybeSingle()
if (error) {
console.log(error)
return reply.code(500).send({ error: 'Database error' })
}*/
if (!data) {
return reply.code(404).send({ error: 'ZIP not found' })
}
//districtMap
const bundeslaender = [
{ code: 'DE-BW', name: 'Baden-Württemberg' },
{ code: 'DE-BY', name: 'Bayern' },
{ code: 'DE-BE', name: 'Berlin' },
{ code: 'DE-BB', name: 'Brandenburg' },
{ code: 'DE-HB', name: 'Bremen' },
{ code: 'DE-HH', name: 'Hamburg' },
{ code: 'DE-HE', name: 'Hessen' },
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
{ code: 'DE-NI', name: 'Niedersachsen' },
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
{ code: 'DE-SL', name: 'Saarland' },
{ code: 'DE-SN', name: 'Sachsen' },
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
{ code: 'DE-TH', name: 'Thüringen' }
]
return reply.send({
...data,
//@ts-ignore
state_code: bundeslaender.find(i => i.name === data.countryName)
})
} catch (err) {
console.log(err)
return reply.code(500).send({ error: 'Internal server error' })
}
})
server.post('/functions/serial/start', async (req, reply) => {
console.log(req.body)
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
})
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
const {execution_id} = req.params as { execution_id: string }
//@ts-ignore
await finishManualGeneration(server,execution_id)
})
server.post('/functions/services/bankstatementsync', async (req, reply) => {
await server.services.bankStatements.run(req.user.tenant_id);
})
server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
})
/*server.post('/print/zpl/preview', async (req, reply) => {
const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}
console.log(widthMm,heightMm,dpmm)
if (!zpl) {
return reply.code(400).send({ error: 'Missing ZPL string' })
}
try {
// 1⃣ Renderer initialisieren
const { api } = await zplReady
// 2⃣ Rendern (liefert base64-encoded PNG)
const base64Png = await api.zplToBase64Async(zpl, widthMm, heightMm, dpmm)
return await encodeBase64ToNiimbot(base64Png, 'top')
} catch (err) {
console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
}
})
server.post('/print/label', async (req, reply) => {
const { context, width=584, heigth=354 } = req.body as {context:any,width:number,heigth:number}
try {
const base64 = await generateLabel(context,width,heigth)
return {
encoded: await encodeBase64ToNiimbot(base64, 'top'),
base64: base64
}
} catch (err) {
console.error('[ZPL Preview Error]', err)
return reply.code(500).send({ error: err.message || 'Failed to render ZPL' })
}
})*/
}

View File

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

View File

@@ -0,0 +1,104 @@
// modules/helpdesk/helpdesk.inbound.email.ts
import { FastifyPluginAsync } from 'fastify'
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
import {extractDomain, findCustomerOrContactByEmailOrDomain} from "../utils/helpers";
import {useNextNumberRangeNumber} from "../utils/functions";
// -------------------------------------------------------------
// 📧 Interne M2M-Route für eingehende E-Mails
// -------------------------------------------------------------
const helpdeskInboundEmailRoutes: FastifyPluginAsync = async (server) => {
server.post('/helpdesk/inbound-email', async (req, res) => {
const {
tenant_id,
channel_id,
from,
subject,
text,
message_id,
in_reply_to,
} = req.body as {
tenant_id: number
channel_id: string
from: {address: string, name: string}
subject: string
text: string
message_id: string
in_reply_to: string
}
if (!tenant_id || !from?.address || !text) {
return res.status(400).send({ error: 'Invalid payload' })
}
server.log.info(`[InboundEmail] Neue Mail von ${from.address} für Tenant ${tenant_id}`)
// 1⃣ Kunde & Kontakt ermitteln
const { customer, contact: contactPerson } =
(await findCustomerOrContactByEmailOrDomain(server, from.address, tenant_id)) || {}
// 2⃣ Kontakt anlegen oder laden
const contact = await getOrCreateContact(server, tenant_id, {
email: from.address,
display_name: from.name || from.address,
customer_id: customer,
contact_id: contactPerson,
})
// 3⃣ Konversation anhand In-Reply-To suchen
let conversationId: string | null = null
if (in_reply_to) {
const { data: msg } = await server.supabase
.from('helpdesk_messages')
.select('conversation_id')
.eq('external_message_id', in_reply_to)
.maybeSingle()
conversationId = msg?.conversation_id || null
}
// 4⃣ Neue Konversation anlegen falls keine existiert
let conversation
if (!conversationId) {
conversation = await createConversation(server, {
tenant_id,
contact,
channel_instance_id: channel_id,
subject: subject || '(kein Betreff)',
customer_id: customer,
contact_person_id: contactPerson,
})
conversationId = conversation.id
} else {
const { data } = await server.supabase
.from('helpdesk_conversations')
.select('*')
.eq('id', conversationId)
.single()
conversation = data
}
// 5⃣ Nachricht speichern
await addMessage(server, {
tenant_id,
conversation_id: conversationId,
direction: 'incoming',
payload: { type: 'text', text },
external_message_id: message_id,
raw_meta: { source: 'email' },
})
server.log.info(`[InboundEmail] Ticket ${conversationId} gespeichert`)
return res.status(201).send({
success: true,
conversation_id: conversationId,
ticket_number: conversation.ticket_number,
})
})
}
export default helpdeskInboundEmailRoutes

View File

@@ -0,0 +1,142 @@
// modules/helpdesk/helpdesk.inbound.routes.ts
import { FastifyPluginAsync } from 'fastify'
import { createConversation } from '../modules/helpdesk/helpdesk.conversation.service.js'
import { addMessage } from '../modules/helpdesk/helpdesk.message.service.js'
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
/**
* Öffentliche Route zum Empfang eingehender Kontaktformular-Nachrichten.
* Authentifizierung: über `public_token` aus helpdesk_channel_instances
*/
function extractDomain(email) {
if (!email) return null
const parts = email.split("@")
return parts.length === 2 ? parts[1].toLowerCase() : null
}
async function findCustomerOrContactByEmailOrDomain(server,fromMail, tenantId) {
const sender = fromMail
const senderDomain = extractDomain(sender)
if (!senderDomain) return null
// 1⃣ Direkter Match über contacts
const { data: contactMatch } = await server.supabase
.from("contacts")
.select("id, customer")
.eq("email", sender)
.eq("tenant", tenantId)
.maybeSingle()
if (contactMatch?.customer_id) return {
customer: contactMatch.customer,
contact: contactMatch.id
}
// 2⃣ Kunden laden, bei denen E-Mail oder Rechnungsmail passt
const { data: customers, error } = await server.supabase
.from("customers")
.select("id, infoData")
.eq("tenant", tenantId)
if (error) {
console.error(`[Helpdesk] Fehler beim Laden der Kunden:`, error.message)
return null
}
// 3⃣ Durch Kunden iterieren und prüfen
for (const c of customers || []) {
const info = c.infoData || {}
const email = info.email?.toLowerCase()
const invoiceEmail = info.invoiceEmail?.toLowerCase()
const emailDomain = extractDomain(email)
const invoiceDomain = extractDomain(invoiceEmail)
// exakter Match oder Domain-Match
if (
sender === email ||
sender === invoiceEmail ||
senderDomain === emailDomain ||
senderDomain === invoiceDomain
) {
return {customer: c.id, contact:null}
}
}
return null
}
const helpdeskInboundRoutes: FastifyPluginAsync = async (server) => {
// Öffentliche POST-Route
server.post('/helpdesk/inbound/:public_token', async (req, res) => {
const { public_token } = req.params as { public_token: string }
const { email, phone, display_name, subject, message } = req.body as {
email: string,
phone: string,
display_name: string
subject: string
message: string
}
if (!message) {
return res.status(400).send({ error: 'Message content required' })
}
// 1⃣ Kanalinstanz anhand des Tokens ermitteln
const { data: channel, error: channelError } = await server.supabase
.from('helpdesk_channel_instances')
.select('*')
.eq('public_token', public_token)
.single()
if (channelError || !channel) {
return res.status(404).send({ error: 'Invalid channel token' })
}
const tenant_id = channel.tenant_id
const channel_instance_id = channel.id
// @ts-ignore
const {customer, contact: contactPerson} = await findCustomerOrContactByEmailOrDomain(server,email, tenant_id )
// 2⃣ Kontakt finden oder anlegen
const contact = await getOrCreateContact(server, tenant_id, {
email,
phone,
display_name,
customer_id: customer,
contact_id: contactPerson,
})
// 3⃣ Konversation erstellen
const conversation = await createConversation(server, {
tenant_id,
contact,
channel_instance_id,
subject: subject ?? 'Kontaktformular Anfrage',
customer_id: customer,
contact_person_id: contactPerson
})
// 4⃣ Erste Nachricht hinzufügen
await addMessage(server, {
tenant_id,
conversation_id: conversation.id,
direction: 'incoming',
payload: { type: 'text', text: message },
raw_meta: { source: 'contact_form' },
})
// (optional) Auto-Antwort oder Event hier ergänzen
return res.status(201).send({
success: true,
conversation_id: conversation.id,
})
})
}
export default helpdeskInboundRoutes

View File

@@ -0,0 +1,331 @@
// modules/helpdesk/helpdesk.routes.ts
import { FastifyPluginAsync } from 'fastify'
import { createConversation, getConversations, updateConversationStatus } from '../modules/helpdesk/helpdesk.conversation.service.js'
import { addMessage, getMessages } from '../modules/helpdesk/helpdesk.message.service.js'
import { getOrCreateContact } from '../modules/helpdesk/helpdesk.contact.service.js'
import {decrypt, encrypt} from "../utils/crypt";
import nodemailer from "nodemailer"
const helpdeskRoutes: FastifyPluginAsync = async (server) => {
// 📩 1. Liste aller Konversationen
server.get('/helpdesk/conversations', async (req, res) => {
const tenant_id = req.user?.tenant_id
if (!tenant_id) return res.status(401).send({ error: 'Unauthorized' })
const { status } = req.query as {status: string}
const conversations = await getConversations(server, tenant_id, { status })
return res.send(conversations)
})
// 🆕 2. Neue Konversation erstellen
server.post('/helpdesk/conversations', async (req, res) => {
const tenant_id = req.user?.tenant_id
if (!tenant_id) return res.status(401).send({ error: 'Unauthorized' })
const { contact, channel_instance_id, subject, message } = req.body as {
contact: object
channel_instance_id: string
subject: string
message: string
}
if (!contact || !channel_instance_id) {
return res.status(400).send({ error: 'Missing contact or channel_instance_id' })
}
// 1. Konversation erstellen
const conversation = await createConversation(server, {
tenant_id,
contact,
channel_instance_id,
subject,
})
// 2. Falls erste Nachricht vorhanden → hinzufügen
if (message) {
await addMessage(server, {
tenant_id,
conversation_id: conversation.id,
direction: 'incoming',
payload: { type: 'text', text: message },
})
}
return res.status(201).send(conversation)
})
// 🧭 3. Einzelne Konversation abrufen
server.get('/helpdesk/conversations/:id', async (req, res) => {
const tenant_id = req.user?.tenant_id
const {id: conversation_id} = req.params as {id: string}
const { data, error } = await server.supabase
.from('helpdesk_conversations')
.select('*, helpdesk_contacts(*)')
.eq('tenant_id', tenant_id)
.eq('id', conversation_id)
.single()
if (error) return res.status(404).send({ error: 'Conversation not found' })
return res.send(data)
})
// 🔄 4. Konversation Status ändern
server.patch('/helpdesk/conversations/:id/status', async (req, res) => {
const {id: conversation_id} = req.params as { id: string }
const { status } = req.body as { status: string }
const updated = await updateConversationStatus(server, conversation_id, status)
return res.send(updated)
})
// 💬 5. Nachrichten abrufen
server.get('/helpdesk/conversations/:id/messages', async (req, res) => {
const {id:conversation_id} = req.params as { id: string }
const messages = await getMessages(server, conversation_id)
return res.send(messages)
})
// 💌 6. Nachricht hinzufügen (z. B. Antwort eines Agents)
server.post('/helpdesk/conversations/:id/messages', async (req, res) => {
console.log(req.user)
const tenant_id = req.user?.tenant_id
const author_user_id = req.user?.user_id
const {id: conversation_id} = req.params as { id: string }
const { text } = req.body as { text: string }
if (!text) return res.status(400).send({ error: 'Missing message text' })
const message = await addMessage(server, {
tenant_id,
conversation_id,
author_user_id,
direction: 'outgoing',
payload: { type: 'text', text },
})
return res.status(201).send(message)
})
// 👤 7. Kontakt suchen oder anlegen
server.post('/helpdesk/contacts', async (req, res) => {
const tenant_id = req.user?.tenant_id
const { email, phone, display_name } = req.body as { email: string; phone: string, display_name: string }
const contact = await getOrCreateContact(server, tenant_id, { email, phone, display_name })
return res.status(201).send(contact)
})
server.post("/helpdesk/channels", {
schema: {
body: {
type: "object",
required: ["type_id", "name", "config"],
properties: {
type_id: { type: "string" },
name: { type: "string" },
config: { type: "object" },
is_active: { type: "boolean", default: true },
},
},
},
handler: async (req, reply) => {
const { type_id, name, config, is_active = true } = req.body as
{
type_id: string,
name: string,
config: {
imap:{
host: string | object,
user: string | object,
pass: string | object,
},
smtp:{
host: string | object,
user: string | object,
pass: string | object,
}
},
is_active: boolean
}
// 🔒 Tenant aus Auth-Context
const tenant_id = req.user?.tenant_id
if (!tenant_id) {
return reply.status(401).send({ error: "Kein Tenant im Benutzerkontext gefunden." })
}
if (type_id !== "email") {
return reply.status(400).send({ error: "Nur Typ 'email' wird aktuell unterstützt." })
}
try {
const safeConfig = { ...config }
// 🔐 IMAP-Daten verschlüsseln
if (safeConfig.imap) {
if (safeConfig.imap.host)
safeConfig.imap.host = encrypt(safeConfig.imap.host)
if (safeConfig.imap.user)
safeConfig.imap.user = encrypt(safeConfig.imap.user)
if (safeConfig.imap.pass)
safeConfig.imap.pass = encrypt(safeConfig.imap.pass)
}
// 🔐 SMTP-Daten verschlüsseln
if (safeConfig.smtp) {
if (safeConfig.smtp.host)
safeConfig.smtp.host = encrypt(safeConfig.smtp.host)
if (safeConfig.smtp.user)
safeConfig.smtp.user = encrypt(safeConfig.smtp.user)
if (safeConfig.smtp.pass)
safeConfig.smtp.pass = encrypt(safeConfig.smtp.pass)
}
// Speichern in Supabase
const { data, error } = await server.supabase
.from("helpdesk_channel_instances")
.insert({
tenant_id,
type_id,
name,
config: safeConfig,
is_active,
})
.select()
.single()
if (error) throw error
// sensible Felder aus Response entfernen
if (data.config?.imap) {
delete data.config.imap.host
delete data.config.imap.user
delete data.config.imap.pass
}
if (data.config?.smtp) {
delete data.config.smtp.host
delete data.config.smtp.user
delete data.config.smtp.pass
}
reply.send({
message: "E-Mail-Channel erfolgreich erstellt",
channel: data,
})
} catch (err) {
console.error("Fehler bei Channel-Erstellung:", err)
reply.status(500).send({ error: err.message })
}
},
})
server.post("/helpdesk/conversations/:id/reply", {
schema: {
body: {
type: "object",
required: ["text"],
properties: {
text: { type: "string" },
},
},
},
handler: async (req, reply) => {
const conversationId = (req.params as any).id
const { text } = req.body as { text: string }
// 🔹 Konversation inkl. Channel + Kontakt laden
const { data: conv, error: convErr } = await server.supabase
.from("helpdesk_conversations")
.select(`
id,
tenant_id,
subject,
channel_instance_id,
helpdesk_contacts(email),
helpdesk_channel_instances(config, name),
ticket_number
`)
.eq("id", conversationId)
.single()
console.log(conv)
if (convErr || !conv) {
reply.status(404).send({ error: "Konversation nicht gefunden" })
return
}
const contact = conv.helpdesk_contacts as unknown as {email: string}
const channel = conv.helpdesk_channel_instances as unknown as {name: string}
console.log(contact)
if (!contact?.email) {
reply.status(400).send({ error: "Kein Empfänger gefunden" })
return
}
// 🔐 SMTP-Daten entschlüsseln
try {
// @ts-ignore
const smtp = channel?.config?.smtp
const host =
typeof smtp.host === "object" ? decrypt(smtp.host) : smtp.host
const user =
typeof smtp.user === "object" ? decrypt(smtp.user) : smtp.user
const pass =
typeof smtp.pass === "object" ? decrypt(smtp.pass) : smtp.pass
// 🔧 Transporter
const transporter = nodemailer.createTransport({
host,
port: smtp.port || 465,
secure: smtp.secure ?? true,
auth: { user, pass },
})
// 📩 Mail senden
const mailOptions = {
from: `"${channel?.name}" <${user}>`,
to: contact.email,
subject: `${conv.ticket_number} | ${conv.subject}` || `${conv.ticket_number} | Antwort vom FEDEO Helpdesk`,
text,
}
const info = await transporter.sendMail(mailOptions)
console.log(`[Helpdesk SMTP] Gesendet an ${contact.email}: ${info.messageId}`)
// 💾 Nachricht speichern
const { error: insertErr } = await server.supabase
.from("helpdesk_messages")
.insert({
tenant_id: conv.tenant_id,
conversation_id: conversationId,
direction: "outgoing",
payload: { type: "text", text },
external_message_id: info.messageId,
received_at: new Date().toISOString(),
})
if (insertErr) throw insertErr
// 🔁 Konversation aktualisieren
await server.supabase
.from("helpdesk_conversations")
.update({ last_message_at: new Date().toISOString() })
.eq("id", conversationId)
reply.send({
message: "E-Mail erfolgreich gesendet",
messageId: info.messageId,
})
} catch (err: any) {
console.error("Fehler beim SMTP-Versand:", err)
reply.status(500).send({ error: err.message })
}
},
})
}
export default helpdeskRoutes

View File

@@ -0,0 +1,156 @@
// src/routes/resources/history.ts
import { FastifyInstance } from "fastify";
const columnMap: Record<string, string> = {
customers: "customer",
vendors: "vendor",
projects: "project",
plants: "plant",
contracts: "contract",
contacts: "contact",
tasks: "task",
vehicles: "vehicle",
events: "event",
files: "file",
products: "product",
inventoryitems: "inventoryitem",
inventoryitemgroups: "inventoryitemgroup",
absencerequests: "absencerequest",
checks: "check",
costcentres: "costcentre",
ownaccounts: "ownaccount",
documentboxes: "documentbox",
hourrates: "hourrate",
services: "service",
roles: "role",
};
export default async function resourceHistoryRoutes(server: FastifyInstance) {
server.get<{
Params: { resource: string; id: string }
}>("/resource/:resource/:id/history", {
schema: {
tags: ["History"],
summary: "Get history entries for a resource",
params: {
type: "object",
required: ["resource", "id"],
properties: {
resource: { type: "string" },
id: { type: "string" },
},
},
},
}, async (req, reply) => {
const { resource, id } = req.params;
const column = columnMap[resource];
if (!column) {
return reply.code(400).send({ error: `History not supported for resource '${resource}'` });
}
const { data, error } = await server.supabase
.from("historyitems")
.select("*")
.eq(column, id)
.order("created_at", { ascending: true });
if (error) {
server.log.error(error);
return reply.code(500).send({ error: "Failed to fetch history" });
}
const {data:users, error:usersError} = await server.supabase
.from("auth_users")
.select("*, auth_profiles(*), tenants!auth_tenant_users(*)")
const filteredUsers = (users ||[]).filter(i => i.tenants.find((t:any) => t.id === req.user?.tenant_id))
const dataCombined = data.map(historyitem => {
return {
...historyitem,
created_by_profile: filteredUsers.find(i => i.id === historyitem.created_by) ? filteredUsers.find(i => i.id === historyitem.created_by).auth_profiles[0] : null
}
})
return dataCombined;
});
// Neuen HistoryItem anlegen
server.post<{
Params: { resource: string; id: string };
Body: {
text: string;
old_val?: string | null;
new_val?: string | null;
config?: Record<string, any>;
};
}>("/resource/:resource/:id/history", {
schema: {
tags: ["History"],
summary: "Create new history entry",
params: {
type: "object",
properties: {
resource: { type: "string" },
id: { type: "string" }
},
required: ["resource", "id"]
},
body: {
type: "object",
properties: {
text: { type: "string" },
old_val: { type: "string", nullable: true },
new_val: { type: "string", nullable: true },
config: { type: "object", nullable: true }
},
required: ["text"]
},
response: {
201: {
type: "object",
properties: {
id: { type: "number" },
text: { type: "string" },
created_at: { type: "string" },
created_by: { type: "string" }
}
}
}
}
}, async (req, reply) => {
const { resource, id } = req.params;
const { text, old_val, new_val, config } = req.body;
const userId = (req.user as any)?.user_id;
const fkField = columnMap[resource];
if (!fkField) {
return reply.code(400).send({ error: `Unknown resource: ${resource}` });
}
const { data, error } = await server.supabase
.from("historyitems")
.insert({
text,
[fkField]: id,
oldVal: old_val || null,
newVal: new_val || null,
config: config || null,
tenant: (req.user as any)?.tenant_id,
created_by: userId
})
.select()
.single();
if (error) {
return reply.code(500).send({ error: error.message });
}
return reply.code(201).send(data);
});
}

View File

@@ -0,0 +1,41 @@
import { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { devices } from "../../../db/schema";
export default async function deviceRoutes(fastify: FastifyInstance) {
fastify.get<{
Params: {
externalId: string;
};
}>(
"/devices/by-external-id/:externalId",
async (request, reply) => {
const { externalId } = request.params;
const device = await fastify.db
.select({
id: devices.id,
name: devices.name,
type: devices.type,
tenant: devices.tenant,
externalId: devices.externalId,
created_at: devices.createdAt,
})
.from(devices)
.where(
eq(devices.externalId, externalId)
)
.limit(1)
.then(rows => rows[0]);
if (!device) {
return reply.status(404).send({
message: "Device not found",
});
}
return reply.send(device);
}
);
}

View File

@@ -0,0 +1,107 @@
import { FastifyInstance } from "fastify"
import {
authTenantUsers,
authUsers,
authProfiles,
tenants
} from "../../../db/schema"
import {and, eq, inArray} from "drizzle-orm"
export default async function tenantRoutesInternal(server: FastifyInstance) {
// -------------------------------------------------------------
// GET CURRENT TENANT
// -------------------------------------------------------------
server.get("/tenant/:id", async (req) => {
//@ts-ignore
const tenant = (await server.db.select().from(tenants).where(eq(tenants.id,req.params.id)).limit(1))[0]
return tenant
})
// -------------------------------------------------------------
// TENANT USERS (auth_users + auth_profiles)
// -------------------------------------------------------------
server.get("/tenant/users", async (req, reply) => {
try {
const authUser = req.user
if (!authUser) return reply.code(401).send({ error: "Unauthorized" })
const tenantId = authUser.tenant_id
// 1) auth_tenant_users → user_ids
const tenantUsers = await server.db
.select()
.from(authTenantUsers)
.where(eq(authTenantUsers.tenant_id, tenantId))
const userIds = tenantUsers.map(u => u.user_id)
if (!userIds.length) {
return { tenant_id: tenantId, users: [] }
}
// 2) auth_users laden
const users = await server.db
.select()
.from(authUsers)
.where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden
const profiles = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds)
))
const combined = users.map(u => {
const profile = profiles.find(p => p.user_id === u.id)
return {
id: u.id,
email: u.email,
profile,
full_name: profile?.full_name ?? null
}
})
return { tenant_id: tenantId, users: combined }
} catch (err) {
console.error("/tenant/users ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// TENANT PROFILES
// -------------------------------------------------------------
server.get("/tenant/:id/profiles", async (req, reply) => {
try {
// @ts-ignore
const tenantId = req.params.id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data = await server.db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId))
return data
} catch (err) {
console.error("/tenant/profiles ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
}

View File

@@ -0,0 +1,122 @@
import { FastifyInstance } from "fastify"
import {
stafftimeentries,
stafftimenetryconnects
} from "../../../db/schema"
import {
eq,
and,
gte,
lte,
desc
} from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod";
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
export default async function staffTimeRoutesInternal(server: FastifyInstance) {
server.post("/staff/time/event", async (req, reply) => {
try {
const body = req.body as {user_id:string,tenant_id:number,eventtime:string,eventtype:string}
const normalizeDate = (val: any) => {
if (!val) return null
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
const dataToInsert = {
tenant_id: body.tenant_id,
user_id: body.user_id,
actortype: "user",
actoruser_id: body.user_id,
eventtime: normalizeDate(body.eventtime),
eventtype: body.eventtype,
source: "WEB"
}
console.log(dataToInsert)
const [created] = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(dataToInsert)
.returning()
return created
} catch (err: any) {
console.error(err)
return reply.code(400).send({ error: err.message })
}
})
// GET /api/staff/time/spans
server.get("/staff/time/spans", async (req, reply) => {
try {
// Query-Parameter: targetUserId ist optional
const { targetUserId, tenantId} = req.query as { targetUserId: string, tenantId:number };
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
const evaluatedUserId = targetUserId;
// 💡 "Unendlicher" Zeitraum, wie gewünscht
const startDate = new Date(0); // 1970
const endDate = new Date("2100-12-31");
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
const allEventsInTimeFrame = await loadValidEvents(
server,
tenantId,
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
startDate,
endDate
);
// SCHRITT 2: Filtere faktische Events
const FACTUAL_EVENT_TYPES = new Set([
"work_start", "work_end", "pause_start", "pause_end",
"sick_start", "sick_end", "vacation_start", "vacation_end",
"overtime_compensation_start", "overtime_compensation_end",
"auto_stop"
]);
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
// SCHRITT 3: Hole administrative Events
const factualEventIds = factualEvents.map(e => e.id);
if (factualEventIds.length === 0) {
return [];
}
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
// SCHRITT 4: Kombinieren und Sortieren
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
// SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents);
// SCHRITT 6: Spans anreichern
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
return enrichedSpans;
} catch (error) {
console.error("Fehler beim Laden der Spans:", error);
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
}
});
}

View File

@@ -0,0 +1,30 @@
// routes/notifications.routes.ts
import { FastifyInstance } from 'fastify';
import { NotificationService, UserDirectory } from '../modules/notification.service';
// Beispiel: E-Mail aus eigener User-Tabelle laden
const getUserDirectory: UserDirectory = async (server:FastifyInstance, userId, tenantId) => {
const { data, error } = await server.supabase
.from('auth_users')
.select('email')
.eq('id', userId)
.maybeSingle();
if (error || !data) return null;
return { email: data.email };
};
export default async function notificationsRoutes(server: FastifyInstance) {
// wichtig: server.supabase ist über app verfügbar
const svc = new NotificationService(server, getUserDirectory);
server.post('/notifications/trigger', async (req, reply) => {
try {
const res = await svc.trigger(req.body as any);
reply.send(res);
} catch (err: any) {
server.log.error(err);
reply.code(500).send({ error: err.message });
}
});
}

View File

@@ -0,0 +1,120 @@
import { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm";
import {
authProfiles,
} from "../../db/schema";
export default async function authProfilesRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// GET SINGLE PROFILE
// -------------------------------------------------------------
server.get("/profiles/:id", async (req, reply) => {
try {
const { id } = req.params as { id: string };
const tenantId = (req.user as any)?.tenant_id;
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
const rows = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId)
)
)
.limit(1);
if (!rows.length) {
return reply.code(404).send({ error: "User not found or not in tenant" });
}
return rows[0];
} catch (error) {
console.error("GET /profiles/:id ERROR:", error);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
function sanitizeProfileUpdate(body: any) {
const cleaned: any = { ...body }
// ❌ Systemfelder entfernen
const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name"
]
forbidden.forEach(f => delete cleaned[f])
// ❌ Falls NULL Strings vorkommen → in null umwandeln
for (const key of Object.keys(cleaned)) {
if (cleaned[key] === "") cleaned[key] = null
}
// ✅ Date-Felder sauber konvertieren, falls vorhanden
const dateFields = ["birthday", "entry_date"]
for (const field of dateFields) {
if (cleaned[field]) {
const d = new Date(cleaned[field])
if (!isNaN(d.getTime())) cleaned[field] = d
else delete cleaned[field] // invalid → entfernen
}
}
return cleaned
}
// -------------------------------------------------------------
// UPDATE PROFILE
// -------------------------------------------------------------
server.put("/profiles/:id", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id } = req.params as { id: string }
let body = req.body as any
// Clean + Normalize
body = sanitizeProfileUpdate(body)
const updateData = {
...body,
updatedAt: new Date(),
updatedBy: userId
}
const updated = await server.db
.update(authProfiles)
.set(updateData)
.where(
and(
eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId)
)
)
.returning()
if (!updated.length) {
return reply.code(404).send({ error: "User not found or not in tenant" })
}
return updated[0]
} catch (err) {
console.error("PUT /profiles/:id ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
}

View File

@@ -0,0 +1,41 @@
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import { publicLinkService } from '../../modules/publiclinks.service';
export default async function publiclinksAuthenticatedRoutes(server: FastifyInstance) {
server.post("/publiclinks", async (req, reply) => {
try {
const tenantId = 21; // Hardcoded für Test, später: req.user.tenantId
const { name, isProtected, pin, customToken, config, defaultProfileId } = req.body as { name:string, isProtected:boolean, pin:string, customToken:string, config:Object, defaultProfileId:string};
const newLink = await publicLinkService.createLink(server, tenantId,
name,
isProtected,
pin,
customToken,
config,
defaultProfileId);
return reply.code(201).send({
success: true,
data: {
id: newLink.id,
token: newLink.token,
fullUrl: `/public/${newLink.token}`, // Helper für Frontend
isProtected: newLink.isProtected
}
});
} catch (error: any) {
server.log.error(error);
// Einfache Fehlerbehandlung
if (error.message.includes("bereits vergeben")) {
return reply.code(409).send({ error: error.message });
}
return reply.code(500).send({ error: "Fehler beim Erstellen des Links", details: error.message });
}
})
}

View File

@@ -0,0 +1,91 @@
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import { publicLinkService } from '../../modules/publiclinks.service';
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
server.get("/workflows/context/:token", async (req, reply) => {
const { token } = req.params as { token: string };
// Wir lesen die PIN aus dem Header (Best Practice für Security)
const pin = req.headers['x-public-pin'] as string | undefined;
try {
const context = await publicLinkService.getLinkContext(server, token, pin);
return reply.send(context);
} catch (error: any) {
// Spezifische Fehlercodes für das Frontend
if (error.message === "Link_NotFound") {
return reply.code(404).send({ error: "Link nicht gefunden oder abgelaufen" });
}
if (error.message === "Pin_Required") {
return reply.code(401).send({
error: "PIN erforderlich",
code: "PIN_REQUIRED",
requirePin: true
});
}
if (error.message === "Pin_Invalid") {
return reply.code(403).send({
error: "PIN falsch",
code: "PIN_INVALID",
requirePin: true
});
}
server.log.error(error);
return reply.code(500).send({ error: "Interner Server Fehler" });
}
});
server.post("/workflows/submit/:token", async (req, reply) => {
const { token } = req.params as { token: string };
// PIN sicher aus dem Header lesen
const pin = req.headers['x-public-pin'] as string | undefined;
// Der Body enthält { profile, project, service, ... }
const payload = req.body;
console.log(payload)
try {
// Service aufrufen (führt die 3 Schritte aus: Lieferschein -> Zeit -> History)
const result = await publicLinkService.submitFormData(server, token, payload, pin);
// 201 Created zurückgeben
return reply.code(201).send(result);
} catch (error: any) {
console.log(error);
// Fehler-Mapping für saubere HTTP Codes
if (error.message === "Link_NotFound") {
return reply.code(404).send({ error: "Link ungültig oder nicht aktiv" });
}
if (error.message === "Pin_Required") {
return reply.code(401).send({ error: "PIN erforderlich" });
}
if (error.message === "Pin_Invalid") {
return reply.code(403).send({ error: "PIN ist falsch" });
}
if (error.message === "Profile_Missing") {
return reply.code(400).send({ error: "Kein Mitarbeiter-Profil gefunden (weder im Link noch in der Eingabe)" });
}
if (error.message === "Project not found" || error.message === "Service not found") {
return reply.code(400).send({ error: "Ausgewähltes Projekt oder Leistung existiert nicht mehr." });
}
// Fallback für alle anderen Fehler (z.B. DB Constraints)
return reply.code(500).send({
error: "Interner Fehler beim Speichern",
details: error.message
});
}
});
}

View File

@@ -0,0 +1,555 @@
import { FastifyInstance } from "fastify"
import {
eq,
ilike,
asc,
desc,
and,
count,
inArray,
or
} from "drizzle-orm"
import {resourceConfig} from "../../utils/resource.config";
import {useNextNumberRangeNumber} from "../../utils/functions";
import {stafftimeentries} from "../../../db/schema";
// -------------------------------------------------------------
// SQL Volltextsuche auf mehreren Feldern
// -------------------------------------------------------------
function buildSearchCondition(table: any, columns: string[], search: string) {
if (!search || !columns.length) return null
const term = `%${search.toLowerCase()}%`
const conditions = columns
.map((colName) => table[colName])
.filter(Boolean)
.map((col) => ilike(col, term))
if (conditions.length === 0) return null
// @ts-ignore
return or(...conditions)
}
export default async function resourceRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// LIST
// -------------------------------------------------------------
server.get("/resource/:resource", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId)
return reply.code(400).send({ error: "No tenant selected" })
const { search, sort, asc: ascQuery } = req.query as {
search?: string
sort?: string
asc?: string
}
const {resource} = req.params as {resource: string}
const table = resourceConfig[resource].table
// WHERE-Basis
let whereCond: any = eq(table.tenant, tenantId)
// 🔍 SQL Search
if(search) {
const searchCond = buildSearchCondition(
table,
resourceConfig[resource].searchColumns,
search.trim()
)
if (searchCond) {
whereCond = and(whereCond, searchCond)
}
}
// Base Query
let q = server.db.select().from(table).where(whereCond)
// Sortierung
if (sort) {
const col = (table as any)[sort]
if (col) {
//@ts-ignore
q = ascQuery === "true"
? q.orderBy(asc(col))
: q.orderBy(desc(col))
}
}
const queryData = await q
// RELATION LOADING (MANY-TO-ONE)
let ids = {}
let lists = {}
let maps = {}
let data = [...queryData]
if(resourceConfig[resource].mtoLoad) {
resourceConfig[resource].mtoLoad.forEach(relation => {
ids[relation] = [...new Set(queryData.map(r => r[relation]).filter(Boolean))];
})
for await (const relation of resourceConfig[resource].mtoLoad ) {
console.log(relation)
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
}
resourceConfig[resource].mtoLoad.forEach(relation => {
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
})
data = queryData.map(row => {
let toReturn = {
...row
}
resourceConfig[resource].mtoLoad.forEach(relation => {
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
})
return toReturn
});
}
if(resourceConfig[resource].mtmListLoad) {
for await (const relation of resourceConfig[resource].mtmListLoad) {
console.log(relation)
console.log(resource.substring(0,resource.length-1))
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
console.log(relationRows.length)
data = data.map(row => {
let toReturn = {
...row
}
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
return toReturn
})
}
}
return data
} catch (err) {
console.error("ERROR /resource/:resource", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// PAGINATED LIST
// -------------------------------------------------------------
server.get("/resource/:resource/paginated", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id;
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
const {resource} = req.params as {resource: string};
const {queryConfig} = req;
const {
pagination,
sort,
filters,
paginationDisabled
} = queryConfig;
const { search, distinctColumns } = req.query as {
search?: string;
distinctColumns?: string;
};
let table = resourceConfig[resource].table
let whereCond: any = eq(table.tenant, tenantId);
if(search) {
const searchCond = buildSearchCondition(
table,
resourceConfig[resource].searchColumns,
search.trim()
)
if (searchCond) {
whereCond = and(whereCond, searchCond)
}
}
if (filters) {
for (const [key, val] of Object.entries(filters)) {
const col = (table as any)[key];
if (!col) continue;
if (Array.isArray(val)) {
whereCond = and(whereCond, inArray(col, val));
} else {
whereCond = and(whereCond, eq(col, val as any));
}
}
}
// -----------------------------------------------
// COUNT (for pagination)
// -----------------------------------------------
const totalRes = await server.db
.select({ value: count(table.id) })
.from(table)
.where(whereCond);
const total = Number(totalRes[0]?.value ?? 0);
// -----------------------------------------------
// DISTINCT VALUES (regardless of pagination)
// -----------------------------------------------
const distinctValues: Record<string, any[]> = {};
if (distinctColumns) {
for (const colName of distinctColumns.split(",").map(c => c.trim())) {
const col = (table as any)[colName];
if (!col) continue;
const rows = await server.db
.select({ v: col })
.from(table)
.where(eq(table.tenant, tenantId));
const values = rows
.map(r => r.v)
.filter(v => v != null && v !== "");
distinctValues[colName] = [...new Set(values)].sort();
}
}
// PAGINATION
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? 100;
// SORTING
let orderField: any = null;
let direction: "asc" | "desc" = "asc";
if (sort?.length > 0) {
const s = sort[0];
const col = (table as any)[s.field];
if (col) {
orderField = col;
direction = s.direction === "asc" ? "asc" : "desc";
}
}
// MAIN QUERY (Paginated)
let q = server.db
.select()
.from(table)
.where(whereCond)
.offset(offset)
.limit(limit);
if (orderField) {
//@ts-ignore
q = direction === "asc"
? q.orderBy(asc(orderField))
: q.orderBy(desc(orderField));
}
const rows = await q;
if (!rows.length) {
return {
data: [],
queryConfig: {
...queryConfig,
total,
totalPages: 0,
distinctValues
}
};
}
let data = [...rows]
//Many to One
if(resourceConfig[resource].mtoLoad) {
let ids = {}
let lists = {}
let maps = {}
resourceConfig[resource].mtoLoad.forEach(relation => {
ids[relation] = [...new Set(rows.map(r => r[relation]).filter(Boolean))];
})
for await (const relation of resourceConfig[resource].mtoLoad ) {
lists[relation] = ids[relation].length ? await server.db.select().from(resourceConfig[relation + "s"].table).where(inArray(resourceConfig[relation + "s"].table.id, ids[relation])) : []
}
resourceConfig[resource].mtoLoad.forEach(relation => {
maps[relation] = Object.fromEntries(lists[relation].map(i => [i.id, i]));
})
data = rows.map(row => {
let toReturn = {
...row
}
resourceConfig[resource].mtoLoad.forEach(relation => {
toReturn[relation] = row[relation] ? maps[relation][row[relation]] : null
})
return toReturn
});
}
if(resourceConfig[resource].mtmListLoad) {
for await (const relation of resourceConfig[resource].mtmListLoad) {
console.log(relation)
const relationRows = await server.db.select().from(resourceConfig[relation].table).where(inArray(resourceConfig[relation].table[resource.substring(0,resource.length-1)],data.map(i => i.id)))
console.log(relationRows)
data = data.map(row => {
let toReturn = {
...row
}
toReturn[relation] = relationRows.filter(i => i[resource.substring(0,resource.length-1)] === row.id)
return toReturn
})
}
}
// -----------------------------------------------
// RETURN DATA
// -----------------------------------------------
return {
data,
queryConfig: {
...queryConfig,
total,
totalPages: Math.ceil(total / limit),
distinctValues
}
};
} catch (err) {
console.error(`ERROR /resource/:resource/paginated:`, err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// DETAIL (mit JOINS)
// -------------------------------------------------------------
server.get("/resource/:resource/:id/:no_relations?", async (req, reply) => {
try {
const { id } = req.params as { id: string }
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const {resource, no_relations} = req.params as { resource: string, no_relations?: boolean }
const table = resourceConfig[resource].table
const projRows = await server.db
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
.limit(1)
if (!projRows.length)
return reply.code(404).send({ error: "Resource not found" })
// ------------------------------------
// LOAD RELATIONS
// ------------------------------------
let ids = {}
let lists = {}
let maps = {}
let data = {
...projRows[0]
}
if(!no_relations) {
if(resourceConfig[resource].mtoLoad) {
for await (const relation of resourceConfig[resource].mtoLoad ) {
if(data[relation]) {
data[relation] = (await server.db.select().from(resourceConfig[relation + "s"].table).where(eq(resourceConfig[relation + "s"].table.id, data[relation])))[0]
}
}
}
if(resourceConfig[resource].mtmLoad) {
for await (const relation of resourceConfig[resource].mtmLoad ) {
console.log(relation)
data[relation] = await server.db.select().from(resourceConfig[relation].table).where(eq(resourceConfig[relation].table[resource.substring(0,resource.length - 1)],id))
}
}
}
return data
} catch (err) {
console.error("ERROR /resource/projects/:id", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// Create
server.post("/resource/:resource", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({error: "No tenant selected"});
}
const {resource} = req.params as { resource: string };
const body = req.body as Record<string, any>;
const table = resourceConfig[resource].table
let createData = {
...body,
tenant: req.user.tenant_id,
archived: false, // Standardwert
}
console.log(resourceConfig[resource].numberRangeHolder)
if (resourceConfig[resource].numberRangeHolder && !body[resourceConfig[resource]]) {
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, resource)
console.log(result)
createData[resourceConfig[resource].numberRangeHolder] = result.usedNumber
}
const normalizeDate = (val: any) => {
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
Object.keys(createData).forEach((key) => {
if(key.toLowerCase().includes("date")) createData[key] = normalizeDate(createData[key])
})
const [created] = await server.db
.insert(table)
.values(createData)
.returning()
/*await insertHistoryItem(server, {
entity: resource,
entityId: data.id,
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: data,
text: `${dataType.labelSingle} erstellt`,
});*/
return created;
} catch (error) {
console.log(error)
reply.status(500)
}
});
// UPDATE (inkl. Soft-Delete/Archive)
server.put("/resource/:resource/:id", async (req, reply) => {
try {
const {resource, id} = req.params as { resource: string; id: string }
const body = req.body as Record<string, any>
const tenantId = (req.user as any)?.tenant_id
const userId = (req.user as any)?.user_id
if (!tenantId || !userId) {
return reply.code(401).send({error: "Unauthorized"})
}
const table = resourceConfig[resource].table
//TODO: HISTORY
const normalizeDate = (val: any) => {
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
let data = {...body, updated_at: new Date().toISOString(), updated_by: userId}
Object.keys(data).forEach((key) => {
if(key.includes("_at") || key.includes("At")) {
data[key] = normalizeDate(data[key])
}
})
console.log(data)
const [updated] = await server.db
.update(table)
.set(data)
.where(and(
eq(table.id, id),
eq(table.tenant, tenantId)))
.returning()
//const diffs = diffObjects(oldItem, newItem);
/*for (const d of diffs) {
await insertHistoryItem(server, {
entity: resource,
entityId: id,
action: d.type,
created_by: userId,
tenant_id: tenantId,
oldVal: d.oldValue ? String(d.oldValue) : null,
newVal: d.newValue ? String(d.newValue) : null,
text: `Feld "${d.label}" ${d.typeLabel}: ${d.oldValue ?? ""} → ${d.newValue ?? ""}`,
});
}*/
return updated
} catch (err) {
console.log("ERROR /resource/projects/:id", err)
}
})
}

View File

@@ -0,0 +1,75 @@
import { FastifyInstance } from "fastify"
import { asc, desc } from "drizzle-orm"
import { sortData } from "../utils/sort"
// Schema imports
import { accounts, units,countrys } from "../../db/schema"
const TABLE_MAP: Record<string, any> = {
accounts,
units,
countrys,
}
export default async function resourceRoutesSpecial(server: FastifyInstance) {
server.get("/resource-special/:resource", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { resource } = req.params as { resource: string }
// ❌ Wenn falsche Ressource
if (!TABLE_MAP[resource]) {
return reply.code(400).send({ error: "Invalid special resource" })
}
const table = TABLE_MAP[resource]
const { select, sort, asc: ascQuery } = req.query as {
select?: string
sort?: string
asc?: string
}
// ---------------------------------------
// 📌 SELECT: wir ignorieren select string (wie Supabase)
// Drizzle kann kein dynamisches Select aus String!
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
// ---------------------------------------
let query = server.db.select().from(table)
// ---------------------------------------
// 📌 Sortierung
// ---------------------------------------
if (sort) {
const col = (table as any)[sort]
if (col) {
//@ts-ignore
query =
ascQuery === "true"
? query.orderBy(asc(col))
: query.orderBy(desc(col))
}
}
const data = await query
// Falls sort clientseitig wie früher notwendig ist:
const sorted = sortData(
data,
sort,
ascQuery === "true"
)
return sorted
}
catch (err) {
console.error(err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
}

View File

@@ -0,0 +1,430 @@
import { FastifyInstance } from "fastify"
import {
stafftimeentries,
stafftimenetryconnects
} from "../../../db/schema"
import {
eq,
and,
gte,
lte,
desc
} from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod";
import {enrichSpansWithStatus} from "../../modules/time/enrichtimespanswithstatus.service";
export default async function staffTimeRoutes(server: FastifyInstance) {
server.post("/staff/time/event", async (req, reply) => {
try {
const userId = req.user.user_id
const tenantId = req.user.tenant_id
const body = req.body as any
const normalizeDate = (val: any) => {
if (!val) return null
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
const dataToInsert = {
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: normalizeDate(body.eventtime),
eventtype: body.eventtype,
source: "WEB",
payload: body.payload // Payload (z.B. Description) mit speichern
}
console.log(dataToInsert)
const [created] = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(dataToInsert)
.returning()
return created
} catch (err: any) {
console.error(err)
return reply.code(400).send({ error: err.message })
}
})
// 🆕 POST /staff/time/edit (Bearbeiten durch Invalidieren + Neu erstellen)
server.post("/staff/time/edit", async (req, reply) => {
try {
const userId = req.user.user_id;
const tenantId = req.user.tenant_id;
// Wir erwarten das komplette Paket für die Änderung
const {
originalEventIds, // Array der IDs, die "gelöscht" werden sollen (Start ID, End ID)
newStart, // ISO String
newEnd, // ISO String
newType, // z.B. 'work', 'vacation'
description,
reason // Warum wurde geändert? (Audit)
} = req.body as {
originalEventIds: string[],
newStart: string,
newEnd: string | null,
newType: string,
description?: string,
reason?: string
};
if (!originalEventIds || originalEventIds.length === 0) {
return reply.code(400).send({ error: "Keine Events zum Bearbeiten angegeben." });
}
// 1. Transaction starten (damit alles oder nichts passiert)
await server.db.transaction(async (tx) => {
// A. INVALIDIEREN (Die alten Events "löschen")
// Wir erstellen für jedes alte Event ein 'invalidated' Event
const invalidations = originalEventIds.map(id => ({
tenant_id: tenantId,
user_id: userId, // Gehört dem Mitarbeiter
actortype: "user",
actoruser_id: userId, // Wer hat geändert?
eventtime: new Date(),
eventtype: "invalidated", // <--- NEUER TYP: Muss in loadValidEvents gefiltert werden!
source: "WEB",
related_event_id: id, // Zeigt auf das alte Event
metadata: {
reason: reason || "Bearbeitung",
replaced_by_edit: true
}
}));
// Batch Insert
// @ts-ignore
await tx.insert(stafftimeevents).values(invalidations);
// B. NEU ERSTELLEN (Die korrigierten Events anlegen)
// Start Event
// @ts-ignore
await tx.insert(stafftimeevents).values({
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: new Date(newStart),
eventtype: `${newType}_start`, // z.B. work_start
source: "WEB",
payload: { description: description || "" }
});
// End Event (nur wenn vorhanden)
if (newEnd) {
// @ts-ignore
await tx.insert(stafftimeevents).values({
tenant_id: tenantId,
user_id: userId,
actortype: "user",
actoruser_id: userId,
eventtime: new Date(newEnd),
eventtype: `${newType}_end`, // z.B. work_end
source: "WEB"
});
}
});
return { success: true };
} catch (err: any) {
console.error("Fehler beim Bearbeiten:", err);
return reply.code(500).send({ error: err.message });
}
});
// POST /staff/time/submit
server.post("/staff/time/submit", async (req, reply) => {
try {
const userId = req.user.user_id; // Mitarbeiter, der einreicht
const tenantId = req.user.tenant_id;
// Erwartet eine Liste von IDs der faktischen Events (work_start, work_end, etc.)
const { eventIds } = req.body as { eventIds: string[] };
if (eventIds.length === 0) {
return reply.code(400).send({ error: "Keine Events zum Einreichen angegeben." });
}
const inserts = eventIds.map((eventId) => ({
tenant_id: tenantId,
user_id: userId, // Event gehört zum Mitarbeiter
actortype: "user",
actoruser_id: userId, // Mitarbeiter ist der Akteur
eventtime: new Date(),
eventtype: "submitted", // NEU: Event-Typ für Einreichung
source: "WEB",
related_event_id: eventId, // Verweis auf das faktische Event
}));
const createdEvents = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(inserts)
.returning();
return { submittedCount: createdEvents.length };
} catch (err: any) {
console.error(err);
return reply.code(500).send({ error: err.message });
}
});
// POST /staff/time/approve
server.post("/staff/time/approve", async (req, reply) => {
try {
// 🚨 Berechtigungsprüfung (Voraussetzung: req.user enthält Manager-Status)
/*if (!req.user.isManager) {
return reply.code(403).send({ error: "Keine Genehmigungsberechtigung." });
}*/
const actorId = req.user.user_id; // Manager ist der Akteur
const tenantId = req.user.tenant_id;
const { eventIds, employeeUserId } = req.body as {
eventIds: string[];
employeeUserId: string; // Die ID des Mitarbeiters, dessen Zeit genehmigt wird
};
if (eventIds.length === 0) {
return reply.code(400).send({ error: "Keine Events zur Genehmigung angegeben." });
}
const inserts = eventIds.map((eventId) => ({
tenant_id: tenantId,
user_id: employeeUserId, // Event gehört zum Mitarbeiter
actortype: "user",
actoruser_id: actorId, // Manager ist der Akteur
eventtime: new Date(),
eventtype: "approved", // NEU: Event-Typ für Genehmigung
source: "WEB",
related_event_id: eventId, // Verweis auf das faktische Event
metadata: {
// Optional: Genehmigungskommentar
approvedBy: req.user.email
}
}));
const createdEvents = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(inserts)
.returning();
return { approvedCount: createdEvents.length };
} catch (err: any) {
console.error(err);
return reply.code(500).send({ error: err.message });
}
});
// POST /staff/time/reject
server.post("/staff/time/reject", async (req, reply) => {
try {
// 🚨 Berechtigungsprüfung
/*if (!req.user.isManager) {
return reply.code(403).send({ error: "Keine Zurückweisungsberechtigung." });
}*/
const actorId = req.user.user_id; // Manager ist der Akteur
const tenantId = req.user.tenant_id;
const { eventIds, employeeUserId, reason } = req.body as {
eventIds: string[];
employeeUserId: string;
reason?: string; // Optionaler Grund für die Ablehnung
};
if (eventIds.length === 0) {
return reply.code(400).send({ error: "Keine Events zur Ablehnung angegeben." });
}
const inserts = eventIds.map((eventId) => ({
tenant_id: tenantId,
user_id: employeeUserId, // Event gehört zum Mitarbeiter
actortype: "user",
actoruser_id: actorId, // Manager ist der Akteur
eventtime: new Date(),
eventtype: "rejected", // NEU: Event-Typ für Ablehnung
source: "WEB",
related_event_id: eventId, // Verweis auf das faktische Event
metadata: {
reason: reason || "Ohne Angabe"
}
}));
const createdEvents = await server.db
.insert(stafftimeevents)
//@ts-ignore
.values(inserts)
.returning();
return { rejectedCount: createdEvents.length };
} catch (err: any) {
console.error(err);
return reply.code(500).send({ error: err.message });
}
});
// GET /api/staff/time/spans
server.get("/staff/time/spans", async (req, reply) => {
try {
// Der eingeloggte User (Anfragesteller)
const actingUserId = req.user.user_id;
const tenantId = req.user.tenant_id;
// Query-Parameter: targetUserId ist optional
const { targetUserId } = req.query as { targetUserId?: string };
// Falls eine targetUserId übergeben wurde, nutzen wir diese, sonst die eigene ID
const evaluatedUserId = targetUserId || actingUserId;
// 💡 "Unendlicher" Zeitraum, wie gewünscht
const startDate = new Date(0); // 1970
const endDate = new Date("2100-12-31");
// SCHRITT 1: Lade ALLE Events für den ZIEL-USER (evaluatedUserId)
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
const allEventsInTimeFrame = await loadValidEvents(
server,
tenantId,
evaluatedUserId, // <--- Hier wird jetzt die variable ID genutzt
startDate,
endDate
);
// SCHRITT 2: Filtere faktische Events
const FACTUAL_EVENT_TYPES = new Set([
"work_start", "work_end", "pause_start", "pause_end",
"sick_start", "sick_end", "vacation_start", "vacation_end",
"overtime_compensation_start", "overtime_compensation_end",
"auto_stop"
]);
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
// SCHRITT 3: Hole administrative Events
const factualEventIds = factualEvents.map(e => e.id);
if (factualEventIds.length === 0) {
return [];
}
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
// SCHRITT 4: Kombinieren und Sortieren
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
// SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents);
// SCHRITT 6: Spans anreichern
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
return enrichedSpans;
} catch (error) {
console.error("Fehler beim Laden der Spans:", error);
return reply.code(500).send({ error: "Interner Fehler beim Laden der Zeitspannen." });
}
});
server.get("/staff/time/evaluation", async (req, reply) => {
try {
// --- 1. Eingangsdaten und Validierung des aktuellen Nutzers ---
// Daten des aktuell eingeloggten (anfragenden) Benutzers
const actingUserId = req.user.user_id;
const tenantId = req.user.tenant_id;
// Query-Parameter extrahieren
const { from, to, targetUserId } = req.query as {
from: string,
to: string,
targetUserId?: string // Optionale ID des Benutzers, dessen Daten abgerufen werden sollen
};
// Die ID, für die die Auswertung tatsächlich durchgeführt wird
const evaluatedUserId = targetUserId || actingUserId;
const startDate = new Date(from);
const endDate = new Date(to);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return reply.code(400).send({ error: "Ungültiges Datumsformat." });
}
// --- 3. Ausführung der Logik für den ermittelten Benutzer ---
// SCHRITT 1: Lade ALLE gültigen Events im Zeitraum
// WICHTIG: loadValidEvents muss "invalidated" Events und deren related_ids ausfiltern!
const allEventsInTimeFrame = await loadValidEvents(
server, tenantId, evaluatedUserId, startDate, endDate // Verwendung der evaluatedUserId
);
// 1b: Trenne Faktische und Administrative Events
const FACTUAL_EVENT_TYPES = new Set([
"work_start", "work_end", "pause_start", "pause_end",
"sick_start", "sick_end", "vacation_start", "vacation_end",
"overtime_compensation_start", "overtime_compensation_end",
"auto_stop"
]);
const factualEvents = allEventsInTimeFrame.filter(e => FACTUAL_EVENT_TYPES.has(e.eventtype));
// 1c: Sammle alle IDs der faktischen Events im Zeitraum
const factualEventIds = factualEvents.map(e => e.id);
// SCHRITT 2: Lade die administrativen Events, die sich auf diese IDs beziehen (auch NACH dem Zeitraum)
const relatedAdminEvents = await loadRelatedAdminEvents(server, factualEventIds);
// SCHRITT 3: Kombiniere alle Events für die Weiterverarbeitung
const combinedEvents = [
...factualEvents,
...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
// SCHRITT 4: Ableiten und Anreichern
const derivedSpans = deriveTimeSpans(combinedEvents);
const enrichedSpans = enrichSpansWithStatus(derivedSpans, combinedEvents);
// SCHRITT 5: Erstellung der finalen Auswertung (Summen und Salden)
const evaluationSummary = await buildTimeEvaluationFromSpans(
server,
evaluatedUserId, // Verwendung der evaluatedUserId
tenantId,
from,
to,
enrichedSpans
);
return {
userId: evaluatedUserId, // Rückgabe der ID, für die ausgewertet wurde
spans: enrichedSpans,
summary: evaluationSummary
};
} catch (error) {
console.error("Fehler in /staff/time/evaluation:", error);
return reply.code(500).send({ error: "Interner Serverfehler bei der Zeitauswertung." });
}
});
}

View File

@@ -0,0 +1,71 @@
import { FastifyInstance } from 'fastify'
import { StaffTimeEntryConnect } from '../../types/staff'
export default async function staffTimeConnectRoutes(server: FastifyInstance) {
// ▶ Connect anlegen
server.post<{ Params: { id: string }, Body: Omit<StaffTimeEntryConnect, 'id' | 'time_entry_id'> }>(
'/staff/time/:id/connects',
async (req, reply) => {
const { id } = req.params
const { started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes } = req.body
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.insert([{ time_entry_id: id, started_at, stopped_at, project_id, customer_id, task_id, ticket_id, notes }])
.select()
.maybeSingle()
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
// ▶ Connects abrufen
server.get<{ Params: { id: string } }>(
'/staff/time/:id/connects',
async (req, reply) => {
const { id } = req.params
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.select('*')
.eq('time_entry_id', id)
.order('started_at', { ascending: true })
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
// ▶ Connect aktualisieren
server.patch<{ Params: { connectId: string }, Body: Partial<StaffTimeEntryConnect> }>(
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
const { data, error } = await server.supabase
.from('staff_time_entry_connects')
.update({ ...req.body, updated_at: new Date().toISOString() })
.eq('id', connectId)
.select()
.maybeSingle()
if (error) return reply.code(400).send({ error: error.message })
return reply.send(data)
}
)
// ▶ Connect löschen
server.delete<{ Params: { connectId: string } }>(
'/staff/time/connects/:connectId',
async (req, reply) => {
const { connectId } = req.params
const { error } = await server.supabase
.from('staff_time_entry_connects')
.delete()
.eq('id', connectId)
if (error) return reply.code(400).send({ error: error.message })
return reply.send({ success: true })
}
)
}

View File

@@ -0,0 +1,244 @@
import { FastifyInstance } from "fastify"
import jwt from "jsonwebtoken"
import { secrets } from "../utils/secrets"
import {
authTenantUsers,
authUsers,
authProfiles,
tenants
} from "../../db/schema"
import {and, eq, inArray} from "drizzle-orm"
export default async function tenantRoutes(server: FastifyInstance) {
// -------------------------------------------------------------
// GET CURRENT TENANT
// -------------------------------------------------------------
server.get("/tenant", async (req) => {
if (req.tenant) {
return {
message: `Hallo vom Tenant ${req.tenant?.name}`,
tenant_id: req.tenant?.id,
}
}
return {
message: "Server ist im MultiTenant-Modus es werden alle verfügbaren Tenants geladen."
}
})
// -------------------------------------------------------------
// SWITCH TENANT
// -------------------------------------------------------------
server.post("/tenant/switch", async (req, reply) => {
try {
if (!req.user) {
return reply.code(401).send({ error: "Unauthorized" })
}
const { tenant_id } = req.body as { tenant_id: string }
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
// prüfen ob der User zu diesem Tenant gehört
const membership = await server.db
.select()
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.user_id, req.user.user_id),
eq(authTenantUsers.tenant_id, Number(tenant_id))
))
if (!membership.length) {
return reply.code(403).send({ error: "Not a member of this tenant" })
}
// JWT neu erzeugen
const token = jwt.sign(
{
user_id: req.user.user_id,
email: req.user.email,
tenant_id,
},
secrets.JWT_SECRET!,
{ expiresIn: "6h" }
)
reply.setCookie("token", token, {
path: "/",
httpOnly: true,
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 3,
})
return { token }
} catch (err) {
console.error("TENANT SWITCH ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// TENANT USERS (auth_users + auth_profiles)
// -------------------------------------------------------------
server.get("/tenant/users", async (req, reply) => {
try {
const authUser = req.user
if (!authUser) return reply.code(401).send({ error: "Unauthorized" })
const tenantId = authUser.tenant_id
// 1) auth_tenant_users → user_ids
const tenantUsers = await server.db
.select()
.from(authTenantUsers)
.where(eq(authTenantUsers.tenant_id, tenantId))
const userIds = tenantUsers.map(u => u.user_id)
if (!userIds.length) {
return { tenant_id: tenantId, users: [] }
}
// 2) auth_users laden
const users = await server.db
.select()
.from(authUsers)
.where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden
const profiles = await server.db
.select()
.from(authProfiles)
.where(
and(
eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds)
))
const combined = users.map(u => {
const profile = profiles.find(p => p.user_id === u.id)
return {
id: u.id,
email: u.email,
profile,
full_name: profile?.full_name ?? null
}
})
return { tenant_id: tenantId, users: combined }
} catch (err) {
console.error("/tenant/users ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// TENANT PROFILES
// -------------------------------------------------------------
server.get("/tenant/profiles", async (req, reply) => {
try {
const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const data = await server.db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId))
return { data }
} catch (err) {
console.error("/tenant/profiles ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// UPDATE NUMBER RANGE
// -------------------------------------------------------------
server.put("/tenant/numberrange/:numberrange", async (req, reply) => {
try {
const user = req.user
if (!user) return reply.code(401).send({ error: "Unauthorized" })
const { numberrange } = req.params as { numberrange: string }
const { numberRange } = req.body as { numberRange: any }
if (!numberRange) {
return reply.code(400).send({ error: "numberRange required" })
}
const tenantId = Number(user.tenant_id)
const currentTenantRows = await server.db
.select()
.from(tenants)
.where(eq(tenants.id, tenantId))
const current = currentTenantRows[0]
if (!current) return reply.code(404).send({ error: "Tenant not found" })
const updatedRanges = {
//@ts-ignore
...current.numberRanges,
[numberrange]: numberRange
}
const updated = await server.db
.update(tenants)
.set({ numberRanges: updatedRanges })
.where(eq(tenants.id, tenantId))
.returning()
return updated[0]
} catch (err) {
console.error("/tenant/numberrange ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
// -------------------------------------------------------------
// UPDATE TENANT OTHER FIELDS
// -------------------------------------------------------------
server.put("/tenant/other/:id", async (req, reply) => {
try {
const user = req.user
if (!user) return reply.code(401).send({ error: "Unauthorized" })
const { id } = req.params as { id: string }
const { data } = req.body as { data: any }
if (!data) return reply.code(400).send({ error: "data required" })
const updated = await server.db
.update(tenants)
.set(data)
.where(eq(tenants.id, Number(user.tenant_id)))
.returning()
return updated[0]
} catch (err) {
console.error("/tenant/other ERROR:", err)
return reply.code(500).send({ error: "Internal Server Error" })
}
})
}

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

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

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

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

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

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

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

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

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

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