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