diff --git a/db/schema/index.ts b/db/schema/index.ts index 195de05..47d8bee 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -70,4 +70,5 @@ export * from "./vehicles" export * from "./vendors" export * from "./staff_time_events" export * from "./serialtypes" -export * from "./serialexecutions" \ No newline at end of file +export * from "./serialexecutions" +export * from "./public_links" \ No newline at end of file diff --git a/db/schema/public_links.ts b/db/schema/public_links.ts new file mode 100644 index 0000000..283f96b --- /dev/null +++ b/db/schema/public_links.ts @@ -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(), +}); \ No newline at end of file diff --git a/package.json b/package.json index c657240..6b2b772 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "handlebars": "^4.7.8", "imapflow": "^1.1.1", "jsonwebtoken": "^9.0.2", + "mailparser": "^3.9.0", "nodemailer": "^7.0.6", "openai": "^6.10.0", "pdf-lib": "^1.17.1", diff --git a/src/index.ts b/src/index.ts index a5b846f..d4155be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,10 @@ 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"; @@ -98,6 +102,8 @@ async function main() { await app.register(helpdeskInboundRoutes); + await app.register(publiclinksNonAuthenticatedRoutes) + await app.register(async (m2mApp) => { await m2mApp.register(authM2m) @@ -133,8 +139,7 @@ async function main() { await subApp.register(staffTimeRoutes); await subApp.register(staffTimeConnectRoutes); await subApp.register(userRoutes); - - + await subApp.register(publiclinksAuthenticatedRoutes); await subApp.register(resourceRoutes); },{prefix: "/api"}) diff --git a/src/modules/cron/bankstatementsync.service.ts b/src/modules/cron/bankstatementsync.service.ts index 9773631..66863b0 100644 --- a/src/modules/cron/bankstatementsync.service.ts +++ b/src/modules/cron/bankstatementsync.service.ts @@ -221,11 +221,17 @@ export function bankStatementService(server: FastifyInstance) { ...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: dayjs().utc().toISOString()}) + .set({syncedAt: normalizeDate(dayjs())}) .where(eq(bankaccounts.id, accId)) } } diff --git a/src/modules/cron/dokuboximport.service.ts b/src/modules/cron/dokuboximport.service.ts new file mode 100644 index 0000000..dd1aaec --- /dev/null +++ b/src/modules/cron/dokuboximport.service.ts @@ -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 + } +} diff --git a/src/modules/publiclinks.service.ts b/src/modules/publiclinks.service.ts new file mode 100644 index 0000000..e9b450a --- /dev/null +++ b/src/modules/publiclinks.service.ts @@ -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, + 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> = {}; + + // --------------------------------------------------------- + // 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 = { + 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}; + } +} \ No newline at end of file diff --git a/src/modules/serialexecution.service.ts b/src/modules/serialexecution.service.ts index 82a98ff..e7166b4 100644 --- a/src/modules/serialexecution.service.ts +++ b/src/modules/serialexecution.service.ts @@ -332,17 +332,33 @@ async function getFileTypeId(server: FastifyInstance,tenantId: number) { // --- Logik Helper (Unverändert zur Business Logik) --- function calculateDateRange(config: any, executionDate: dayjs.Dayjs) { - let firstDate = executionDate; - let lastDate = executionDate; - // Logik 1:1 übernommen + // Basis nehmen + let baseDate = executionDate; + + let firstDate = baseDate; + let lastDate = baseDate; + if (config.intervall === "monatlich" && config.dateDirection === "Rückwirkend") { - firstDate = executionDate.subtract(1, "month").date(1); - lastDate = executionDate.subtract(1, "month").endOf("month"); + // 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 = 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) { @@ -453,7 +469,7 @@ function calculateDocumentTotals(rows: any[], taxType: string) { let titleSums: Record = {}; // Aktueller Titel für Gruppierung - let currentTitle = "Ohne Titel"; + let currentTitle = ""; rows.forEach(row => { if (row.mode === 'title') { @@ -467,8 +483,8 @@ function calculateDocumentTotals(rows: any[], taxType: string) { totalNet += amount; // Summen pro Titel addieren - if (!titleSums[currentTitle]) titleSums[currentTitle] = 0; - titleSums[currentTitle] += amount; + //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); diff --git a/src/plugins/cors.ts b/src/plugins/cors.ts index a140d1e..e62e9c3 100644 --- a/src/plugins/cors.ts +++ b/src/plugins/cors.ts @@ -15,7 +15,7 @@ export default fp(async (server: FastifyInstance) => { "capacitor://localhost", // dein Nuxt-Frontend ], 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 credentials: true, // wichtig, falls du Cookies nutzt }); diff --git a/src/routes/publiclinks/publiclinks-authenticated.ts b/src/routes/publiclinks/publiclinks-authenticated.ts new file mode 100644 index 0000000..33e8fa5 --- /dev/null +++ b/src/routes/publiclinks/publiclinks-authenticated.ts @@ -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 }); + } + }) +} diff --git a/src/routes/publiclinks/publiclinks-non-authenticated.ts b/src/routes/publiclinks/publiclinks-non-authenticated.ts new file mode 100644 index 0000000..ded3b64 --- /dev/null +++ b/src/routes/publiclinks/publiclinks-non-authenticated.ts @@ -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 + }); + } + }); +}