Added Backend
This commit is contained in:
406
backend/src/modules/publiclinks.service.ts
Normal file
406
backend/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};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user