From bf3f0cc7842b818c811a9b06697c0f3de7a6d6c6 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 6 Jan 2026 11:43:51 +0100 Subject: [PATCH] Added Public Links --- db/schema/index.ts | 3 +- db/schema/public_links.ts | 30 ++ src/index.ts | 9 +- src/modules/publiclinks.service.ts | 406 ++++++++++++++++++ .../publiclinks/publiclinks-authenticated.ts | 41 ++ .../publiclinks-non-authenticated.ts | 91 ++++ 6 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 db/schema/public_links.ts create mode 100644 src/modules/publiclinks.service.ts create mode 100644 src/routes/publiclinks/publiclinks-authenticated.ts create mode 100644 src/routes/publiclinks/publiclinks-non-authenticated.ts 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/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/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/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 + }); + } + }); +}