@@ -70,4 +70,5 @@ export * from "./vehicles"
|
|||||||
export * from "./vendors"
|
export * from "./vendors"
|
||||||
export * from "./staff_time_events"
|
export * from "./staff_time_events"
|
||||||
export * from "./serialtypes"
|
export * from "./serialtypes"
|
||||||
export * from "./serialexecutions"
|
export * from "./serialexecutions"
|
||||||
|
export * from "./public_links"
|
||||||
30
db/schema/public_links.ts
Normal file
30
db/schema/public_links.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { pgTable, text, integer, boolean, jsonb, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { tenants } from './tenants';
|
||||||
|
import { authProfiles } from './auth_profiles';
|
||||||
|
|
||||||
|
export const publicLinks = pgTable('public_links', {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Der öffentliche Token (z.B. "werkstatt-tablet-01")
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
|
||||||
|
// Zuordnung zum Tenant (WICHTIG für die Datentrennung)
|
||||||
|
tenant: integer('tenant').references(() => tenants.id).notNull(),
|
||||||
|
|
||||||
|
defaultProfile: uuid('default_profile').references(() => authProfiles.id),
|
||||||
|
|
||||||
|
// Sicherheit
|
||||||
|
isProtected: boolean('is_protected').default(false).notNull(),
|
||||||
|
pinHash: text('pin_hash'),
|
||||||
|
|
||||||
|
// Konfiguration (JSON)
|
||||||
|
config: jsonb('config').default({}),
|
||||||
|
|
||||||
|
// Metadaten
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
|
||||||
|
active: boolean('active').default(true).notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow(),
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"imapflow": "^1.1.1",
|
"imapflow": "^1.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mailparser": "^3.9.0",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"openai": "^6.10.0",
|
"openai": "^6.10.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import notificationsRoutes from "./routes/notifications";
|
|||||||
import staffTimeRoutes from "./routes/staff/time";
|
import staffTimeRoutes from "./routes/staff/time";
|
||||||
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
||||||
import userRoutes from "./routes/auth/user";
|
import userRoutes from "./routes/auth/user";
|
||||||
|
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||||
|
|
||||||
|
//Public Links
|
||||||
|
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||||
|
|
||||||
//Resources
|
//Resources
|
||||||
import resourceRoutes from "./routes/resources/main";
|
import resourceRoutes from "./routes/resources/main";
|
||||||
@@ -98,6 +102,8 @@ async function main() {
|
|||||||
|
|
||||||
await app.register(helpdeskInboundRoutes);
|
await app.register(helpdeskInboundRoutes);
|
||||||
|
|
||||||
|
await app.register(publiclinksNonAuthenticatedRoutes)
|
||||||
|
|
||||||
|
|
||||||
await app.register(async (m2mApp) => {
|
await app.register(async (m2mApp) => {
|
||||||
await m2mApp.register(authM2m)
|
await m2mApp.register(authM2m)
|
||||||
@@ -133,8 +139,7 @@ async function main() {
|
|||||||
await subApp.register(staffTimeRoutes);
|
await subApp.register(staffTimeRoutes);
|
||||||
await subApp.register(staffTimeConnectRoutes);
|
await subApp.register(staffTimeConnectRoutes);
|
||||||
await subApp.register(userRoutes);
|
await subApp.register(userRoutes);
|
||||||
|
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||||
|
|
||||||
await subApp.register(resourceRoutes);
|
await subApp.register(resourceRoutes);
|
||||||
|
|
||||||
},{prefix: "/api"})
|
},{prefix: "/api"})
|
||||||
|
|||||||
@@ -221,11 +221,17 @@ export function bankStatementService(server: FastifyInstance) {
|
|||||||
...new Set(allNewTransactions.map((t) => t.account)),
|
...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) {
|
for (const accId of affectedAccounts) {
|
||||||
await server.db
|
await server.db
|
||||||
.update(bankaccounts)
|
.update(bankaccounts)
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
.set({syncedAt: dayjs().utc().toISOString()})
|
.set({syncedAt: normalizeDate(dayjs())})
|
||||||
.where(eq(bankaccounts.id, accId))
|
.where(eq(bankaccounts.id, accId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
259
src/modules/cron/dokuboximport.service.ts
Normal file
259
src/modules/cron/dokuboximport.service.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import { ImapFlow } from "imapflow"
|
||||||
|
import { simpleParser } from "mailparser"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
|
||||||
|
import {saveFile} from "../../utils/files";
|
||||||
|
import { secrets } from "../../utils/secrets"
|
||||||
|
|
||||||
|
// Drizzle Imports
|
||||||
|
import {
|
||||||
|
tenants,
|
||||||
|
folders,
|
||||||
|
filetags,
|
||||||
|
} from "../../../db/schema"
|
||||||
|
|
||||||
|
import {
|
||||||
|
eq,
|
||||||
|
and,
|
||||||
|
} from "drizzle-orm"
|
||||||
|
|
||||||
|
let badMessageDetected = false
|
||||||
|
let badMessageMessageSent = false
|
||||||
|
|
||||||
|
let client: ImapFlow | null = null
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// IMAP CLIENT INITIALIZEN
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
export async function initDokuboxClient() {
|
||||||
|
client = new ImapFlow({
|
||||||
|
host: secrets.DOKUBOX_IMAP_HOST,
|
||||||
|
port: secrets.DOKUBOX_IMAP_PORT,
|
||||||
|
secure: secrets.DOKUBOX_IMAP_SECURE,
|
||||||
|
auth: {
|
||||||
|
user: secrets.DOKUBOX_IMAP_USER,
|
||||||
|
pass: secrets.DOKUBOX_IMAP_PASSWORD
|
||||||
|
},
|
||||||
|
logger: false
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("Dokubox E-Mail Client Initialized")
|
||||||
|
|
||||||
|
await client.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// MAIN SYNC FUNCTION (DRIZZLE VERSION)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
export const syncDokubox = (server: FastifyInstance) =>
|
||||||
|
async () => {
|
||||||
|
|
||||||
|
console.log("Perform Dokubox Sync")
|
||||||
|
|
||||||
|
await initDokuboxClient()
|
||||||
|
|
||||||
|
if (!client?.usable) {
|
||||||
|
throw new Error("E-Mail Client not usable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// TENANTS LADEN (DRIZZLE)
|
||||||
|
// -------------------------------
|
||||||
|
const tenantList = await server.db
|
||||||
|
.select({
|
||||||
|
id: tenants.id,
|
||||||
|
name: tenants.name,
|
||||||
|
emailAddresses: tenants.dokuboxEmailAddresses,
|
||||||
|
key: tenants.dokuboxkey
|
||||||
|
})
|
||||||
|
.from(tenants)
|
||||||
|
|
||||||
|
const lock = await client.getMailboxLock("INBOX")
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
for await (let msg of client.fetch({ seen: false }, { envelope: true, source: true })) {
|
||||||
|
|
||||||
|
const parsed = await simpleParser(msg.source)
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
id: msg.uid,
|
||||||
|
subject: parsed.subject,
|
||||||
|
to: parsed.to?.value || [],
|
||||||
|
cc: parsed.cc?.value || [],
|
||||||
|
attachments: parsed.attachments || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------
|
||||||
|
// MAPPING / FIND TENANT
|
||||||
|
// -------------------------------------------------
|
||||||
|
const config = await getMessageConfigDrizzle(server, message, tenantList)
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
badMessageDetected = true
|
||||||
|
|
||||||
|
if (!badMessageMessageSent) {
|
||||||
|
badMessageMessageSent = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.attachments.length > 0) {
|
||||||
|
for (const attachment of message.attachments) {
|
||||||
|
await saveFile(
|
||||||
|
server,
|
||||||
|
config.tenant,
|
||||||
|
message.id,
|
||||||
|
attachment,
|
||||||
|
config.folder,
|
||||||
|
config.filetype
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!badMessageDetected) {
|
||||||
|
badMessageDetected = false
|
||||||
|
badMessageMessageSent = false
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.messageFlagsAdd({ seen: false }, ["\\Seen"])
|
||||||
|
await client.messageDelete({ seen: true })
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// TENANT ERKENNEN + FOLDER/FILETYPES (DRIZZLE VERSION)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
const getMessageConfigDrizzle = async (
|
||||||
|
server: FastifyInstance,
|
||||||
|
message,
|
||||||
|
tenantsList: any[]
|
||||||
|
) => {
|
||||||
|
|
||||||
|
let possibleKeys: string[] = []
|
||||||
|
|
||||||
|
if (message.to) {
|
||||||
|
message.to.forEach((item) =>
|
||||||
|
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.cc) {
|
||||||
|
message.cc.forEach((item) =>
|
||||||
|
possibleKeys.push(item.address.split("@")[0].toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// TENANT IDENTIFY
|
||||||
|
// -------------------------------------------
|
||||||
|
let tenant = tenantsList.find((t) => possibleKeys.includes(t.key))
|
||||||
|
|
||||||
|
if (!tenant && message.to?.length) {
|
||||||
|
const address = message.to[0].address.toLowerCase()
|
||||||
|
|
||||||
|
tenant = tenantsList.find((t) =>
|
||||||
|
(t.emailAddresses || []).map((m) => m.toLowerCase()).includes(address)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenant) return null
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// FOLDER + FILETYPE VIA SUBJECT
|
||||||
|
// -------------------------------------------
|
||||||
|
let folderId = null
|
||||||
|
let filetypeId = null
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// Rechnung / Invoice
|
||||||
|
// -------------------------------------------
|
||||||
|
if (message.subject?.match(/(Rechnung|Beleg|Invoice|Quittung)/gi)) {
|
||||||
|
|
||||||
|
const folder = await server.db
|
||||||
|
.select({ id: folders.id })
|
||||||
|
.from(folders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(folders.tenant, tenant.id),
|
||||||
|
and(
|
||||||
|
eq(folders.function, "incomingInvoices"),
|
||||||
|
//@ts-ignore
|
||||||
|
eq(folders.year, dayjs().format("YYYY"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
folderId = folder[0]?.id ?? null
|
||||||
|
|
||||||
|
const tag = await server.db
|
||||||
|
.select({ id: filetags.id })
|
||||||
|
.from(filetags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(filetags.tenant, tenant.id),
|
||||||
|
eq(filetags.incomingDocumentType, "invoices")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
filetypeId = tag[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// Mahnung
|
||||||
|
// -------------------------------------------
|
||||||
|
else if (message.subject?.match(/(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/gi)) {
|
||||||
|
|
||||||
|
const tag = await server.db
|
||||||
|
.select({ id: filetags.id })
|
||||||
|
.from(filetags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(filetags.tenant, tenant.id),
|
||||||
|
eq(filetags.incomingDocumentType, "reminders")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
filetypeId = tag[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------
|
||||||
|
// Sonstige Dokumente → Deposit Folder
|
||||||
|
// -------------------------------------------
|
||||||
|
else {
|
||||||
|
|
||||||
|
const folder = await server.db
|
||||||
|
.select({ id: folders.id })
|
||||||
|
.from(folders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(folders.tenant, tenant.id),
|
||||||
|
eq(folders.function, "deposit")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
folderId = folder[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant: tenant.id,
|
||||||
|
folder: folderId,
|
||||||
|
filetype: filetypeId
|
||||||
|
}
|
||||||
|
}
|
||||||
406
src/modules/publiclinks.service.ts
Normal file
406
src/modules/publiclinks.service.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import {and, eq, inArray, not} from 'drizzle-orm';
|
||||||
|
import * as schema from '../../db/schema';
|
||||||
|
import {useNextNumberRangeNumber} from "../utils/functions"; // Pfad anpassen
|
||||||
|
|
||||||
|
|
||||||
|
export const publicLinkService = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Public Link
|
||||||
|
*/
|
||||||
|
async createLink(server: FastifyInstance, tenantId: number,
|
||||||
|
name: string,
|
||||||
|
isProtected?: boolean,
|
||||||
|
pin?: string,
|
||||||
|
customToken?: string,
|
||||||
|
config?: Record<string, any>,
|
||||||
|
defaultProfileId?: string) {
|
||||||
|
let pinHash: string | null = null;
|
||||||
|
|
||||||
|
// 1. PIN Hashen, falls Schutz aktiviert ist
|
||||||
|
if (isProtected && pin) {
|
||||||
|
pinHash = await bcrypt.hash(pin, 10);
|
||||||
|
} else if (isProtected && !pin) {
|
||||||
|
throw new Error("Für geschützte Links muss eine PIN angegeben werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Token generieren oder Custom Token verwenden
|
||||||
|
let token = customToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Generiere einen zufälligen Token (z.B. hex string)
|
||||||
|
// Alternativ: nanoid nutzen, falls installiert
|
||||||
|
token = crypto.randomBytes(12).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen, ob Token schon existiert (nur bei Custom Token wichtig)
|
||||||
|
if (customToken) {
|
||||||
|
const existing = await server.db.query.publicLinks.findFirst({
|
||||||
|
where: eq(schema.publicLinks.token, token)
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Der Token '${token}' ist bereits vergeben.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. DB Insert
|
||||||
|
const [newLink] = await server.db.insert(schema.publicLinks).values({
|
||||||
|
tenant: tenantId,
|
||||||
|
name: name,
|
||||||
|
token: token,
|
||||||
|
isProtected: isProtected || false,
|
||||||
|
pinHash: pinHash,
|
||||||
|
config: config || {},
|
||||||
|
defaultProfile: defaultProfileId || null,
|
||||||
|
active: true
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
return newLink;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listet alle Links für einen Tenant auf (für die Verwaltungs-UI später)
|
||||||
|
*/
|
||||||
|
async getLinksByTenant(server: FastifyInstance, tenantId: number) {
|
||||||
|
return server.db.select()
|
||||||
|
.from(schema.publicLinks)
|
||||||
|
.where(eq(schema.publicLinks.tenant, tenantId));
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
async getLinkContext(server: FastifyInstance, token: string, providedPin?: string) {
|
||||||
|
// 1. Link laden & Checks
|
||||||
|
const linkConfig = await server.db.query.publicLinks.findFirst({
|
||||||
|
where: eq(schema.publicLinks.token, token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!linkConfig || !linkConfig.active) throw new Error("Link_NotFound");
|
||||||
|
|
||||||
|
// 2. PIN Check
|
||||||
|
if (linkConfig.isProtected) {
|
||||||
|
if (!providedPin) throw new Error("Pin_Required");
|
||||||
|
const isValid = await bcrypt.compare(providedPin, linkConfig.pinHash || "");
|
||||||
|
if (!isValid) throw new Error("Pin_Invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = linkConfig.tenant;
|
||||||
|
const config = linkConfig.config as any;
|
||||||
|
|
||||||
|
// --- RESSOURCEN & FILTER ---
|
||||||
|
|
||||||
|
// Standardmäßig alles laden, wenn 'resources' nicht definiert ist
|
||||||
|
const requestedResources: string[] = config.resources || ['profiles', 'projects', 'services', 'units'];
|
||||||
|
const filters = config.filters || {}; // Erwartet jetzt: { projects: [1,2], services: [3,4] }
|
||||||
|
|
||||||
|
const queryPromises: Record<string, Promise<any[]>> = {};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// 1. PROFILES (Mitarbeiter)
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
if (requestedResources.includes('profiles')) {
|
||||||
|
let profileCondition = and(
|
||||||
|
eq(schema.authProfiles.tenant_id, tenantId),
|
||||||
|
eq(schema.authProfiles.active, true)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sicherheits-Feature: Default Profil erzwingen
|
||||||
|
if (linkConfig.defaultProfile) {
|
||||||
|
profileCondition = and(profileCondition, eq(schema.authProfiles.id, linkConfig.defaultProfile));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Auch hier Filter ermöglichen (falls man z.B. nur 3 bestimmte MA zur Auswahl geben will)
|
||||||
|
if (filters.profiles && Array.isArray(filters.profiles) && filters.profiles.length > 0) {
|
||||||
|
profileCondition = and(profileCondition, inArray(schema.authProfiles.id, filters.profiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
queryPromises.profiles = server.db.select({
|
||||||
|
id: schema.authProfiles.id,
|
||||||
|
fullName: schema.authProfiles.full_name
|
||||||
|
})
|
||||||
|
.from(schema.authProfiles)
|
||||||
|
.where(profileCondition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// 2. PROJECTS (Aufträge)
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
if (requestedResources.includes('projects')) {
|
||||||
|
let projectCondition = and(
|
||||||
|
eq(schema.projects.tenant, tenantId),
|
||||||
|
not(eq(schema.projects.active_phase, 'Abgeschlossen'))
|
||||||
|
);
|
||||||
|
|
||||||
|
// NEU: Zugriff direkt auf filters.projects
|
||||||
|
if (filters.projects && Array.isArray(filters.projects) && filters.projects.length > 0) {
|
||||||
|
projectCondition = and(projectCondition, inArray(schema.projects.id, filters.projects));
|
||||||
|
}
|
||||||
|
|
||||||
|
queryPromises.projects = server.db.select({
|
||||||
|
id: schema.projects.id,
|
||||||
|
name: schema.projects.name
|
||||||
|
})
|
||||||
|
.from(schema.projects)
|
||||||
|
.where(projectCondition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// 3. SERVICES (Tätigkeiten)
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
if (requestedResources.includes('services')) {
|
||||||
|
let serviceCondition = eq(schema.services.tenant, tenantId);
|
||||||
|
|
||||||
|
// NEU: Zugriff direkt auf filters.services
|
||||||
|
if (filters.services && Array.isArray(filters.services) && filters.services.length > 0) {
|
||||||
|
serviceCondition = and(serviceCondition, inArray(schema.services.id, filters.services));
|
||||||
|
}
|
||||||
|
|
||||||
|
queryPromises.services = server.db.select({
|
||||||
|
id: schema.services.id,
|
||||||
|
name: schema.services.name,
|
||||||
|
unit: schema.services.unit
|
||||||
|
})
|
||||||
|
.from(schema.services)
|
||||||
|
.where(serviceCondition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// 4. UNITS (Einheiten)
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
if (requestedResources.includes('units')) {
|
||||||
|
// Units werden meist global geladen, könnten aber auch gefiltert werden
|
||||||
|
queryPromises.units = server.db.select().from(schema.units);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QUERY AUSFÜHRUNG ---
|
||||||
|
const results = await Promise.all(Object.values(queryPromises));
|
||||||
|
const keys = Object.keys(queryPromises);
|
||||||
|
|
||||||
|
const dataResponse: Record<string, any[]> = {
|
||||||
|
profiles: [],
|
||||||
|
projects: [],
|
||||||
|
services: [],
|
||||||
|
units: []
|
||||||
|
};
|
||||||
|
|
||||||
|
keys.forEach((key, index) => {
|
||||||
|
dataResponse[key] = results[index];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: linkConfig.config,
|
||||||
|
meta: {
|
||||||
|
formName: linkConfig.name,
|
||||||
|
defaultProfileId: linkConfig.defaultProfile
|
||||||
|
},
|
||||||
|
data: dataResponse
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitFormData(server: FastifyInstance, token: string, payload: any, providedPin?: string) {
|
||||||
|
// 1. Validierung (Token & PIN)
|
||||||
|
const linkConfig = await server.db.query.publicLinks.findFirst({
|
||||||
|
where: eq(schema.publicLinks.token, token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!linkConfig || !linkConfig.active) throw new Error("Link_NotFound");
|
||||||
|
|
||||||
|
if (linkConfig.isProtected) {
|
||||||
|
if (!providedPin) throw new Error("Pin_Required");
|
||||||
|
const isValid = await bcrypt.compare(providedPin, linkConfig.pinHash || "");
|
||||||
|
if (!isValid) throw new Error("Pin_Invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = linkConfig.tenant;
|
||||||
|
const config = linkConfig.config as any;
|
||||||
|
|
||||||
|
// 2. USER ID AUFLÖSEN
|
||||||
|
// Wir holen die profileId aus dem Link (Default) oder dem Payload (User-Auswahl)
|
||||||
|
const rawProfileId = linkConfig.defaultProfile || payload.profile;
|
||||||
|
if (!rawProfileId) throw new Error("Profile_Missing");
|
||||||
|
|
||||||
|
// Profil laden, um die user_id zu bekommen
|
||||||
|
const authProfile = await server.db.query.authProfiles.findFirst({
|
||||||
|
where: eq(schema.authProfiles.id, rawProfileId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authProfile) throw new Error("Profile_Not_Found");
|
||||||
|
|
||||||
|
// Da du sagtest, es gibt immer einen User, verlassen wir uns darauf.
|
||||||
|
// Falls null, werfen wir einen Fehler, da die DB sonst beim Insert knallt.
|
||||||
|
const userId = authProfile.user_id;
|
||||||
|
if (!userId) throw new Error("Profile_Has_No_User_Assigned");
|
||||||
|
|
||||||
|
|
||||||
|
// Helper für Datum
|
||||||
|
const normalizeDate = (val: any) => {
|
||||||
|
if (!val) return null
|
||||||
|
const d = new Date(val)
|
||||||
|
return isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// SCHRITT A: Stammdaten laden
|
||||||
|
// =================================================================
|
||||||
|
const project = await server.db.query.projects.findFirst({
|
||||||
|
where: eq(schema.projects.id, payload.project)
|
||||||
|
});
|
||||||
|
if (!project) throw new Error("Project not found");
|
||||||
|
|
||||||
|
const customer = await server.db.query.customers.findFirst({
|
||||||
|
where: eq(schema.customers.id, project.customer)
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = await server.db.query.services.findFirst({
|
||||||
|
where: eq(schema.services.id, payload.service)
|
||||||
|
});
|
||||||
|
if (!service) throw new Error("Service not found");
|
||||||
|
|
||||||
|
// Texttemplates & Letterhead laden
|
||||||
|
const texttemplates = await server.db.query.texttemplates.findMany({
|
||||||
|
where: (t, {and, eq}) => and(
|
||||||
|
eq(t.tenant, tenantId),
|
||||||
|
eq(t.documentType, 'deliveryNotes')
|
||||||
|
)
|
||||||
|
});
|
||||||
|
const letterhead = await server.db.query.letterheads.findFirst({
|
||||||
|
where: eq(schema.letterheads.tenant, tenantId)
|
||||||
|
});
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// SCHRITT B: Nummernkreis generieren
|
||||||
|
// =================================================================
|
||||||
|
const {usedNumber} = await useNextNumberRangeNumber(server, tenantId, "deliveryNotes");
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// SCHRITT C: Berechnungen
|
||||||
|
// =================================================================
|
||||||
|
const startDate = normalizeDate(payload.startDate) || new Date();
|
||||||
|
let endDate = normalizeDate(payload.endDate);
|
||||||
|
|
||||||
|
// Fallback Endzeit (+1h)
|
||||||
|
if (!endDate) {
|
||||||
|
endDate = server.dayjs(startDate).add(1, 'hour').toDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menge berechnen
|
||||||
|
let quantity = payload.quantity;
|
||||||
|
if (!quantity && payload.totalHours) quantity = payload.totalHours;
|
||||||
|
if (!quantity) {
|
||||||
|
const diffMin = server.dayjs(endDate).diff(server.dayjs(startDate), 'minute');
|
||||||
|
quantity = Number((diffMin / 60).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// SCHRITT D: Lieferschein erstellen
|
||||||
|
// =================================================================
|
||||||
|
const createDocData = {
|
||||||
|
tenant: tenantId,
|
||||||
|
type: "deliveryNotes",
|
||||||
|
state: "Entwurf",
|
||||||
|
customer: project.customer,
|
||||||
|
//@ts-ignore
|
||||||
|
address: customer ? {zip: customer.infoData.zip, city: customer.infoData.city, street: customer.infoData.street,} : {},
|
||||||
|
project: project.id,
|
||||||
|
documentNumber: usedNumber,
|
||||||
|
documentDate: String(new Date()), // Schema sagt 'text', evtl toISOString() besser?
|
||||||
|
deliveryDate: String(startDate), // Schema sagt 'text'
|
||||||
|
deliveryDateType: "Leistungsdatum",
|
||||||
|
createdBy: userId, // WICHTIG: Hier die User ID
|
||||||
|
created_by: userId, // WICHTIG: Hier die User ID
|
||||||
|
title: "Lieferschein",
|
||||||
|
description: "",
|
||||||
|
startText: texttemplates.find((i: any) => i.default && i.pos === "startText")?.text || "",
|
||||||
|
endText: texttemplates.find((i: any) => i.default && i.pos === "endText")?.text || "",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
pos: "1",
|
||||||
|
mode: "service",
|
||||||
|
service: service.id,
|
||||||
|
text: service.name,
|
||||||
|
unit: service.unit,
|
||||||
|
quantity: quantity,
|
||||||
|
description: service.description || null,
|
||||||
|
descriptionText: service.description || null,
|
||||||
|
agriculture: {
|
||||||
|
dieselUsage: payload.dieselUsage || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
contactPerson: userId, // WICHTIG: Hier die User ID
|
||||||
|
letterhead: letterhead?.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [createdDoc] = await server.db.insert(schema.createddocuments)
|
||||||
|
//@ts-ignore
|
||||||
|
.values(createDocData)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// SCHRITT E: Zeiterfassung Events
|
||||||
|
// =================================================================
|
||||||
|
if (config.features?.timeTracking) {
|
||||||
|
|
||||||
|
// Metadaten für das Event
|
||||||
|
const eventMetadata = {
|
||||||
|
project: project.id,
|
||||||
|
service: service.id,
|
||||||
|
description: payload.description || "",
|
||||||
|
generatedDocumentId: createdDoc.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. START EVENT
|
||||||
|
await server.db.insert(schema.stafftimeevents).values({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: userId, // WICHTIG: User ID
|
||||||
|
actortype: "user",
|
||||||
|
actoruser_id: userId, // WICHTIG: User ID
|
||||||
|
eventtime: startDate,
|
||||||
|
eventtype: "START",
|
||||||
|
source: "PUBLIC_LINK",
|
||||||
|
metadata: eventMetadata // WICHTIG: Schema heißt 'metadata', nicht 'payload'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. STOP EVENT
|
||||||
|
await server.db.insert(schema.stafftimeevents).values({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
user_id: userId,
|
||||||
|
actortype: "user",
|
||||||
|
actoruser_id: userId,
|
||||||
|
eventtime: endDate,
|
||||||
|
eventtype: "STOP",
|
||||||
|
source: "PUBLIC_LINK",
|
||||||
|
metadata: eventMetadata
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// SCHRITT F: History Items
|
||||||
|
// =================================================================
|
||||||
|
const historyItemsToCreate = [];
|
||||||
|
|
||||||
|
if (payload.description) {
|
||||||
|
historyItemsToCreate.push({
|
||||||
|
tenant: tenantId,
|
||||||
|
createdBy: userId, // WICHTIG: User ID
|
||||||
|
text: `Notiz aus Webformular Lieferschein ${createdDoc.documentNumber}: ${payload.description}`,
|
||||||
|
project: project.id,
|
||||||
|
createdDocument: createdDoc.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
historyItemsToCreate.push({
|
||||||
|
tenant: tenantId,
|
||||||
|
createdBy: userId, // WICHTIG: User ID
|
||||||
|
text: `Webformular abgeschickt. Lieferschein ${createdDoc.documentNumber} erstellt. Zeit gebucht (Start/Stop).`,
|
||||||
|
project: project.id,
|
||||||
|
createdDocument: createdDoc.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.db.insert(schema.historyitems).values(historyItemsToCreate);
|
||||||
|
|
||||||
|
return {success: true, documentNumber: createdDoc.documentNumber};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -332,17 +332,33 @@ async function getFileTypeId(server: FastifyInstance,tenantId: number) {
|
|||||||
// --- Logik Helper (Unverändert zur Business Logik) ---
|
// --- Logik Helper (Unverändert zur Business Logik) ---
|
||||||
|
|
||||||
function calculateDateRange(config: any, executionDate: dayjs.Dayjs) {
|
function calculateDateRange(config: any, executionDate: dayjs.Dayjs) {
|
||||||
let firstDate = executionDate;
|
// Basis nehmen
|
||||||
let lastDate = executionDate;
|
let baseDate = executionDate;
|
||||||
// Logik 1:1 übernommen
|
|
||||||
|
let firstDate = baseDate;
|
||||||
|
let lastDate = baseDate;
|
||||||
|
|
||||||
if (config.intervall === "monatlich" && config.dateDirection === "Rückwirkend") {
|
if (config.intervall === "monatlich" && config.dateDirection === "Rückwirkend") {
|
||||||
firstDate = executionDate.subtract(1, "month").date(1);
|
// 1. Monat abziehen
|
||||||
lastDate = executionDate.subtract(1, "month").endOf("month");
|
// 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") {
|
} else if (config.intervall === "vierteljährlich" && config.dateDirection === "Rückwirkend") {
|
||||||
firstDate = executionDate.subtract(1, "quarter").startOf("quarter");
|
|
||||||
lastDate = executionDate.subtract(1, "quarter").endOf("quarter");
|
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);
|
||||||
}
|
}
|
||||||
return { firstDate: firstDate.toISOString(), lastDate: lastDate.toISOString() };
|
|
||||||
|
// 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) {
|
async function getSaveData(item: any, tenant: any, firstDate: string, lastDate: string, executionDate: string, executedBy: string) {
|
||||||
@@ -453,7 +469,7 @@ function calculateDocumentTotals(rows: any[], taxType: string) {
|
|||||||
let titleSums: Record<string, number> = {};
|
let titleSums: Record<string, number> = {};
|
||||||
|
|
||||||
// Aktueller Titel für Gruppierung
|
// Aktueller Titel für Gruppierung
|
||||||
let currentTitle = "Ohne Titel";
|
let currentTitle = "";
|
||||||
|
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
if (row.mode === 'title') {
|
if (row.mode === 'title') {
|
||||||
@@ -467,8 +483,8 @@ function calculateDocumentTotals(rows: any[], taxType: string) {
|
|||||||
totalNet += amount;
|
totalNet += amount;
|
||||||
|
|
||||||
// Summen pro Titel addieren
|
// Summen pro Titel addieren
|
||||||
if (!titleSums[currentTitle]) titleSums[currentTitle] = 0;
|
//if (!titleSums[currentTitle]) titleSums[currentTitle] = 0;
|
||||||
titleSums[currentTitle] += amount;
|
if(currentTitle.length > 0) titleSums[currentTitle] += amount;
|
||||||
|
|
||||||
// Steuer-Logik
|
// Steuer-Logik
|
||||||
const tax = taxType === "19 UStG" || taxType === "13b UStG" ? 0 : Number(row.taxPercent);
|
const tax = taxType === "19 UStG" || taxType === "13b UStG" ? 0 : Number(row.taxPercent);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default fp(async (server: FastifyInstance) => {
|
|||||||
"capacitor://localhost", // dein Nuxt-Frontend
|
"capacitor://localhost", // dein Nuxt-Frontend
|
||||||
],
|
],
|
||||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allowedHeaders: ["Content-Type", "Authorization", "Context"],
|
allowedHeaders: ["Content-Type", "Authorization", "Context", "X-Public-Pin"],
|
||||||
exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
|
exposedHeaders: ["Authorization", "Content-Disposition", "Content-Type", "Content-Length"], // optional, falls du ihn auch auslesen willst
|
||||||
credentials: true, // wichtig, falls du Cookies nutzt
|
credentials: true, // wichtig, falls du Cookies nutzt
|
||||||
});
|
});
|
||||||
|
|||||||
41
src/routes/publiclinks/publiclinks-authenticated.ts
Normal file
41
src/routes/publiclinks/publiclinks-authenticated.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||||
|
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||||
|
|
||||||
|
|
||||||
|
export default async function publiclinksAuthenticatedRoutes(server: FastifyInstance) {
|
||||||
|
server.post("/publiclinks", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const tenantId = 21; // Hardcoded für Test, später: req.user.tenantId
|
||||||
|
|
||||||
|
const { name, isProtected, pin, customToken, config, defaultProfileId } = req.body as { name:string, isProtected:boolean, pin:string, customToken:string, config:Object, defaultProfileId:string};
|
||||||
|
|
||||||
|
const newLink = await publicLinkService.createLink(server, tenantId,
|
||||||
|
name,
|
||||||
|
isProtected,
|
||||||
|
pin,
|
||||||
|
customToken,
|
||||||
|
config,
|
||||||
|
defaultProfileId);
|
||||||
|
|
||||||
|
return reply.code(201).send({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: newLink.id,
|
||||||
|
token: newLink.token,
|
||||||
|
fullUrl: `/public/${newLink.token}`, // Helper für Frontend
|
||||||
|
isProtected: newLink.isProtected
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
server.log.error(error);
|
||||||
|
|
||||||
|
// Einfache Fehlerbehandlung
|
||||||
|
if (error.message.includes("bereits vergeben")) {
|
||||||
|
return reply.code(409).send({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(500).send({ error: "Fehler beim Erstellen des Links", details: error.message });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
91
src/routes/publiclinks/publiclinks-non-authenticated.ts
Normal file
91
src/routes/publiclinks/publiclinks-non-authenticated.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user