This commit is contained in:
2025-08-31 18:29:29 +02:00
parent aeaba64865
commit 97a095b422
21 changed files with 1990 additions and 0 deletions

103
src/utils/diff.ts Normal file
View File

@@ -0,0 +1,103 @@
import {diffTranslations} from "./diffTranslations";
export type DiffChange = {
key: string;
label: string;
oldValue: any;
newValue: any;
type: "created" | "updated" | "deleted" | "unchanged";
typeLabel: "erstellt" | "geändert" | "gelöscht" | "unverändert";
};
const IGNORED_KEYS = new Set([
"updated_at",
"updated_by",
"created_at",
"created_by",
"id",
"phases"
]);
/**
* Vergleicht zwei Objekte und gibt die Änderungen zurück.
* @param obj1 Altes Objekt
* @param obj2 Neues Objekt
* @param ctx Lookup-Objekte (z. B. { projects, customers, vendors, profiles, plants })
*/
export function diffObjects(
obj1: Record<string, any>,
obj2: Record<string, any>,
ctx: Record<string, any> = {}
): DiffChange[] {
const diffs: DiffChange[] = [];
const allKeys = new Set([
...Object.keys(obj1 || {}),
...Object.keys(obj2 || {}),
]);
for (const key of allKeys) {
if (IGNORED_KEYS.has(key)) continue; // Felder überspringen
const oldVal = obj1?.[key];
const newVal = obj2?.[key];
console.log(oldVal, key, newVal);
// Wenn beides null/undefined → ignorieren
if (
(oldVal === null || oldVal === undefined || oldVal === "" || JSON.stringify(oldVal) === "[]") &&
(newVal === null || newVal === undefined || newVal === "" || JSON.stringify(newVal) === "[]")
) {
continue;
}
let type: DiffChange["type"] = "unchanged";
let typeLabel: DiffChange["typeLabel"] = "unverändert";
if (oldVal === newVal) {
type = "unchanged";
typeLabel = "unverändert";
} else if (oldVal === undefined) {
type = "created";
typeLabel = "erstellt"
} else if (newVal === undefined) {
type = "deleted";
typeLabel = "gelöscht"
} else {
type = "updated";
typeLabel = "geändert"
}
if (type === "unchanged") continue;
const translation = diffTranslations[key];
let label = key;
let resolvedOld = oldVal;
let resolvedNew = newVal;
if (translation) {
label = translation.label;
if (translation.resolve) {
const { oldVal: resOld, newVal: resNew } = translation.resolve(
oldVal,
newVal,
ctx
);
resolvedOld = resOld;
resolvedNew = resNew;
}
}
diffs.push({
key,
label,
typeLabel,
oldValue: resolvedOld ?? "-",
newValue: resolvedNew ?? "-",
type,
});
}
return diffs;
}

View File

@@ -0,0 +1,165 @@
import dayjs from "dayjs";
type ValueResolver = (
oldVal: any,
newVal: any,
ctx?: Record<string, any>
) => { oldVal: any; newVal: any };
export const diffTranslations: Record<
string,
{ label: string; resolve?: ValueResolver }
> = {
project: {
label: "Projekt",
resolve: (o, n, ctx) => ({
oldVal: o ? ctx?.projects?.find((i: any) => i.id === o)?.name ?? "-" : "-",
newVal: n ? ctx?.projects?.find((i: any) => i.id === n)?.name ?? "-" : "-",
}),
},
title: { label: "Titel" },
type: { label: "Typ" },
notes: { label: "Notizen" },
link: { label: "Link" },
start: {
label: "Start",
resolve: (o, n) => ({
oldVal: o ? dayjs(o).format("DD.MM.YYYY HH:mm") : "-",
newVal: n ? dayjs(n).format("DD.MM.YYYY HH:mm") : "-",
}),
},
end: {
label: "Ende",
resolve: (o, n) => ({
oldVal: o ? dayjs(o).format("DD.MM.YYYY HH:mm") : "-",
newVal: n ? dayjs(n).format("DD.MM.YYYY HH:mm") : "-",
}),
},
birthday: {
label: "Geburtstag",
resolve: (o, n) => ({
oldVal: o ? dayjs(o).format("DD.MM.YYYY") : "-",
newVal: n ? dayjs(n).format("DD.MM.YYYY") : "-",
}),
},
resources: {
label: "Resourcen",
resolve: (o, n) => ({
oldVal: Array.isArray(o) ? o.map((i: any) => i.title).join(", ") : "-",
newVal: Array.isArray(n) ? n.map((i: any) => i.title).join(", ") : "-",
}),
},
customerNumber: { label: "Kundennummer" },
active: {
label: "Aktiv",
resolve: (o, n) => ({
oldVal: o === true ? "Aktiv" : "Gesperrt",
newVal: n === true ? "Aktiv" : "Gesperrt",
}),
},
isCompany: {
label: "Firmenkunde",
resolve: (o, n) => ({
oldVal: o === true ? "Firma" : "Privatkunde",
newVal: n === true ? "Firma" : "Privatkunde",
}),
},
special: { label: "Adresszusatz" },
street: { label: "Straße & Hausnummer" },
city: { label: "Ort" },
zip: { label: "Postleitzahl" },
country: { label: "Land" },
web: { label: "Webseite" },
email: { label: "E-Mail" },
tel: { label: "Telefon" },
ustid: { label: "USt-ID" },
role: { label: "Rolle" },
phoneHome: { label: "Festnetz" },
phoneMobile: { label: "Mobiltelefon" },
salutation: { label: "Anrede" },
firstName: { label: "Vorname" },
lastName: { label: "Nachname" },
name: { label: "Name" },
nameAddition: { label: "Name Zusatz" },
approved: { label: "Genehmigt" },
manufacturer: { label: "Hersteller" },
purchasePrice: { label: "Kaufpreis" },
purchaseDate: { label: "Kaufdatum" },
serialNumber: { label: "Seriennummer" },
usePlanning: { label: "In Plantafel verwenden" },
currentSpace: { label: "Lagerplatz" },
customer: {
label: "Kunde",
resolve: (o, n, ctx) => ({
oldVal: o ? ctx?.customers?.find((i: any) => i.id === o)?.name ?? "-" : "-",
newVal: n ? ctx?.customers?.find((i: any) => i.id === n)?.name ?? "-" : "-",
}),
},
vendor: {
label: "Lieferant",
resolve: (o, n, ctx) => ({
oldVal: o ? ctx?.vendors?.find((i: any) => i.id === o)?.name ?? "-" : "-",
newVal: n ? ctx?.vendors?.find((i: any) => i.id === n)?.name ?? "-" : "-",
}),
},
description: { label: "Beschreibung" },
categorie: { label: "Kategorie" },
profile: {
label: "Mitarbeiter",
resolve: (o, n, ctx) => ({
oldVal: o ? ctx?.profiles?.find((i: any) => i.id === o)?.fullName ?? "-" : "-",
newVal: n ? ctx?.profiles?.find((i: any) => i.id === n)?.fullName ?? "-" : "-",
}),
},
plant: {
label: "Objekt",
resolve: (o, n, ctx) => ({
oldVal: o ? ctx?.plants?.find((i: any) => i.id === o)?.name ?? "-" : "-",
newVal: n ? ctx?.plants?.find((i: any) => i.id === n)?.name ?? "-" : "-",
}),
},
annualPaidLeaveDays: { label: "Urlaubstage" },
employeeNumber: { label: "Mitarbeiternummer" },
weeklyWorkingDays: { label: "Wöchentliche Arbeitstage" },
weeklyWorkingHours: { label: "Wöchentliche Arbeitszeit" },
customerRef: { label: "Referenz des Kunden" },
licensePlate: { label: "Kennzeichen" },
tankSize: { label: "Tankvolumen" },
towingCapacity: { label: "Anhängelast" },
color: { label: "Farbe" },
customPaymentDays: { label: "Zahlungsziel in Tagen" },
customSurchargePercentage: { label: "Individueller Aufschlag" },
powerInKW: { label: "Leistung" },
driver: {
label: "Fahrer",
resolve: (o, n, ctx) => ({
oldVal: o ? ctx?.profiles?.find((i: any) => i.id === o)?.fullName ?? "-" : "-",
newVal: n ? ctx?.profiles?.find((i: any) => i.id === n)?.fullName ?? "-" : "-",
}),
},
projecttype: { label: "Projekttyp" },
fixed: {
label: "Festgeschrieben",
resolve: (o, n) => ({
oldVal: o === true ? "Ja" : "Nein",
newVal: n === true ? "Ja" : "Nein",
}),
},
archived: {
label: "Archiviert",
resolve: (o, n) => ({
oldVal: o === true ? "Ja" : "Nein",
newVal: n === true ? "Ja" : "Nein",
}),
},
};

69
src/utils/history.ts Normal file
View File

@@ -0,0 +1,69 @@
import { FastifyInstance } from "fastify"
export async function insertHistoryItem(
server: FastifyInstance,
params: {
tenant_id: number
created_by: string | null
entity: string
entityId: string | number
action: "created" | "updated" | "unchanged" | "deleted" | "archived"
oldVal?: any
newVal?: any
text?: string
}
) {
const textMap = {
created: `Neuer Eintrag in ${params.entity} erstellt`,
updated: `Eintrag in ${params.entity} geändert`,
archived: `Eintrag in ${params.entity} archiviert`,
deleted: `Eintrag in ${params.entity} gelöscht`
}
const columnMap: Record<string, string> = {
customers: "customer",
vendors: "vendor",
projects: "project",
plants: "plant",
contacts: "contact",
inventoryitems: "inventoryitem",
products: "product",
profiles: "profile",
absencerequests: "absencerequest",
events: "event",
tasks: "task",
vehicles: "vehicle",
costcentres: "costcentre",
ownaccounts: "ownaccount",
documentboxes: "documentbox",
hourrates: "hourrate",
services: "service",
roles: "role",
checks: "check",
spaces: "space",
trackingtrips: "trackingtrip",
createddocuments: "createddocument",
inventoryitemgroups: "inventoryitemgroup"
}
const fkColumn = columnMap[params.entity]
if (!fkColumn) {
server.log.warn(`Keine History-Spalte für Entity: ${params.entity}`)
return
}
const entry = {
tenant: params.tenant_id,
created_by: params.created_by,
text: params.text || textMap[params.action],
action: params.action,
[fkColumn]: params.entityId,
oldVal: params.oldVal ? JSON.stringify(params.oldVal) : null,
newVal: params.newVal ? JSON.stringify(params.newVal) : null
}
const { error } = await server.supabase.from("historyitems").insert([entry])
if (error) { // @ts-ignore
console.log(error)
}
}

32
src/utils/mailer.ts Normal file
View File

@@ -0,0 +1,32 @@
import nodemailer from "nodemailer"
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SSL === "true", // true für 465, false für andere Ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
export async function sendMail(
to: string,
subject: string,
html: string
): Promise<{ success: boolean; info?: any; error?: any }> {
try {
const info = await transporter.sendMail({
from: process.env.MAIL_FROM,
to,
subject,
html,
})
// Nodemailer liefert eine Info-Response zurück
return { success: true, info }
} catch (err) {
console.error("❌ Fehler beim Mailversand:", err)
return { success: false, error: err }
}
}

15
src/utils/password.ts Normal file
View File

@@ -0,0 +1,15 @@
import bcrypt from "bcrypt"
export function generateRandomPassword(length = 12): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"
let password = ""
for (let i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length))
}
return password
}
export async function hashPassword(password: string): Promise<string> {
const saltRounds = 10
return bcrypt.hash(password, saltRounds)
}