import { FastifyInstance } from "fastify"; import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf"; import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions"; import { GetObjectCommand } from "@aws-sdk/client-s3"; import { execFile } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; 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, files} from "../../db/schema"; import {and, eq, isNull, not} from "drizzle-orm"; import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; import { s3 } from "../utils/s3"; import { secrets } from "../utils/secrets"; import { storeExtractedTextForFile } from "../utils/documentText"; import { generateLiquidityForecast } from "../utils/liquidityForecast"; dayjs.extend(customParseFormat) dayjs.extend(isoWeek) dayjs.extend(isBetween) dayjs.extend(isSameOrAfter) dayjs.extend(isSameOrBefore) dayjs.extend(duration) dayjs.extend(timezone) const execFileAsync = promisify(execFile) function resolveGitRoot() { const searchRoots = [ process.cwd(), path.resolve(process.cwd(), ".."), path.resolve(__dirname, "../../.."), path.resolve(__dirname, "../../../.."), ] for (const startDir of searchRoots) { let currentDir = startDir while (currentDir && currentDir !== path.dirname(currentDir)) { if (existsSync(path.join(currentDir, ".git"))) { return currentDir } currentDir = path.dirname(currentDir) } } return null } function getDeploymentChangelogFallback() { const backendPackagePath = path.resolve(process.cwd(), "package.json") let version = "unbekannt" if (existsSync(backendPackagePath)) { try { const packageJson = JSON.parse(readFileSync(backendPackagePath, "utf-8")) version = packageJson?.version || version } catch (err) { console.error("Could not read backend package.json for changelog fallback", err) } } const commitHash = process.env.RAILWAY_GIT_COMMIT_SHA || process.env.VERCEL_GIT_COMMIT_SHA || process.env.GITHUB_SHA || process.env.COMMIT_SHA || process.env.SOURCE_COMMIT || null const committedAt = process.env.BUILD_DATE || process.env.RENDER_GIT_COMMIT_DATE || process.env.VERCEL_GIT_COMMIT_DATE || new Date().toISOString() return [{ hash: commitHash || `version-${version}`, shortHash: commitHash ? commitHash.slice(0, 7) : `v${version}`, subject: `Bereitgestellte Version ${version}`, authorName: "Deployment", committedAt }] } export default async function functionRoutes(server: FastifyInstance) { const streamToBuffer = async (stream: any): Promise => 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))); }); 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 } const normalizedZip = String(zip || "").replace(/\D/g, "") if (normalizedZip.length !== 5) { return reply.code(400).send({ error: 'ZIP must contain exactly 5 digits' }) } try { const data = await server.db .select() .from(citys) .where(eq(citys.zip, Number(normalizedZip))) if (!data.length) { return reply.code(404).send({ error: 'ZIP not found' }) } const city = data[0] //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({ ...city, state_code: bundeslaender.find(i => i.name === city.countryName)?.code || null }) } catch (err) { console.log(err) return reply.code(500).send({ error: 'Internal server error' }) } }) server.get('/functions/changelog', async (req, reply) => { const { limit } = req.query as { limit?: string | number } const parsedLimit = Number(limit) const safeLimit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 50) : 15 const gitRoot = resolveGitRoot() if (!gitRoot) { return reply.send({ repositoryRoot: null, source: 'deployment', entries: getDeploymentChangelogFallback().slice(0, safeLimit) }) } try { const { stdout } = await execFileAsync('git', [ '-C', gitRoot, 'log', `--max-count=${safeLimit}`, '--date=iso-strict', '--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e' ]) const entries = stdout .split('\x1e') .map(entry => entry.trim()) .filter(Boolean) .map(entry => { const [hash, shortHash, subject, authorName, committedAt] = entry.split('\x1f') return { hash, shortHash, subject, authorName, committedAt } }) return reply.send({ repositoryRoot: gitRoot, source: 'git', entries }) } catch (err) { req.log.error(err) return reply.send({ repositoryRoot: gitRoot, source: 'deployment', entries: getDeploymentChangelogFallback().slice(0, safeLimit) }) } }) 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.get('/functions/liquidity-forecast', async (req, reply) => { const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string } const ignoredKeys = String(ignoredRecurringKeys || "") .split(",") .map((key) => key.trim()) .filter(Boolean) try { return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys) } catch (err) { req.log.error(err) return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." }) } }) server.post('/functions/services/backfillfiletext', async (req, reply) => { const tenantId = req.user.tenant_id const pendingFiles = await server.db .select() .from(files) .where( and( eq(files.tenant, tenantId), eq(files.archived, false), not(isNull(files.path)), isNull(files.extractedText) ) ) let processed = 0 let withText = 0 let errors = 0 for (const file of pendingFiles) { try { const response: any = await s3.send(new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path! })) const fileBuffer = await streamToBuffer(response.Body) const result = await storeExtractedTextForFile( server, file.id, fileBuffer, file.mimeType, file.name || file.path?.split("/").pop() ) processed += 1 if (result.text) withText += 1 } catch (err) { errors += 1 server.log.error(`Failed to backfill extracted text for file ${file.id}`) server.log.error(err) } } return { pending: pendingFiles.length, processed, withText, errors } }) server.post('/functions/services/syncdokubox', async (req, reply) => { await server.services.dokuboxSync.run() }) server.post('/print/label', async (req, reply) => { const { context, width = 584, height = 354 } = req.body as {context:any,width:number,height:number} try { const base64 = await generateLabel(context,width,height) return { encoded: await encodeBase64ToNiimbot(base64, 'top'), base64: base64 } } catch (err) { console.error('[Label Render Error]', err) return reply.code(500).send({ error: err.message || 'Failed to render label' }) } }) }