save email as pdf likae attachment version 2

This commit is contained in:
duffyduck 2026-02-04 19:49:09 +01:00
parent d98c97a81f
commit 2d052c76d9
21 changed files with 1143 additions and 151 deletions

View File

@ -1 +1 @@
{"version":3,"file":"contract.controller.d.ts","sourceRoot":"","sources":["../../src/controllers/contract.controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAG5C,OAAO,EAAe,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAE7D,wBAAsB,YAAY,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CA+BjF;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BhF;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBpF;AAED,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtF;AAED,wBAAsB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvF;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUlF;AAID,wBAAsB,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/E"}
{"version":3,"file":"contract.controller.d.ts","sourceRoot":"","sources":["../../src/controllers/contract.controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAG5C,OAAO,EAAe,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAE7D,wBAAsB,YAAY,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCjF;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BhF;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAU/E;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBpF;AAED,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtF;AAED,wBAAsB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvF;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAUlF;AAID,wBAAsB,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/E"}

View File

@ -48,7 +48,13 @@ const contractService = __importStar(require("../services/contract.service.js"))
const contractCockpitService = __importStar(require("../services/contractCockpit.service.js"));
async function getContracts(req, res) {
try {
const { customerId, type, status, search, page, limit } = req.query;
const { customerId, type, status, search, page, limit, tree } = req.query;
// Baumstruktur für Kundenansicht
if (tree === 'true' && customerId) {
const treeData = await contractService.getContractTreeForCustomer(parseInt(customerId));
res.json({ success: true, data: treeData });
return;
}
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden-Verträge anzeigen
let customerIds;
if (req.user?.isCustomerPortal && req.user.customerId) {

File diff suppressed because one or more lines are too long

View File

@ -67,6 +67,19 @@ export declare function getAllContracts(filters: ContractFilters): Promise<{
color: string | null;
sortOrder: number;
} | null;
billingAddress: {
id: number;
customerId: number;
createdAt: Date;
updatedAt: Date;
type: import(".prisma/client").$Enums.AddressType;
isDefault: boolean;
street: string;
houseNumber: string;
postalCode: string;
city: string;
country: string;
} | null;
cancellationPeriod: {
id: number;
isActive: boolean;
@ -99,6 +112,7 @@ export declare function getAllContracts(filters: ContractFilters): Promise<{
contractNumber: string;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
bankCardId: number | null;
identityDocumentId: number | null;
salesPlatformId: number | null;
@ -354,6 +368,19 @@ export declare function getContractById(id: number, decryptPassword?: boolean):
color: string | null;
sortOrder: number;
} | null;
billingAddress: {
id: number;
customerId: number;
createdAt: Date;
updatedAt: Date;
type: import(".prisma/client").$Enums.AddressType;
isDefault: boolean;
street: string;
houseNumber: string;
postalCode: string;
city: string;
country: string;
} | null;
cancellationPeriod: {
id: number;
isActive: boolean;
@ -490,6 +517,7 @@ export declare function getContractById(id: number, decryptPassword?: boolean):
contractNumber: string;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
bankCardId: number | null;
identityDocumentId: number | null;
salesPlatformId: number | null;
@ -534,6 +562,7 @@ export declare function getContractById(id: number, decryptPassword?: boolean):
contractNumber: string;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
bankCardId: number | null;
identityDocumentId: number | null;
salesPlatformId: number | null;
@ -563,6 +592,7 @@ interface ContractCreateData {
contractCategoryId?: number;
status?: ContractStatus;
addressId?: number;
billingAddressId?: number;
bankCardId?: number;
identityDocumentId?: number;
salesPlatformId?: number;
@ -786,6 +816,19 @@ export declare function createContract(data: ContractCreateData): Promise<{
internetUsername: string | null;
internetPasswordEncrypted: string | null;
}) | null;
billingAddress: {
id: number;
customerId: number;
createdAt: Date;
updatedAt: Date;
type: import(".prisma/client").$Enums.AddressType;
isDefault: boolean;
street: string;
houseNumber: string;
postalCode: string;
city: string;
country: string;
} | null;
} & {
id: number;
customerId: number;
@ -802,6 +845,7 @@ export declare function createContract(data: ContractCreateData): Promise<{
contractNumber: string;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
bankCardId: number | null;
identityDocumentId: number | null;
salesPlatformId: number | null;
@ -1050,6 +1094,19 @@ export declare function updateContract(id: number, data: Partial<ContractCreateD
color: string | null;
sortOrder: number;
} | null;
billingAddress: {
id: number;
customerId: number;
createdAt: Date;
updatedAt: Date;
type: import(".prisma/client").$Enums.AddressType;
isDefault: boolean;
street: string;
houseNumber: string;
postalCode: string;
city: string;
country: string;
} | null;
cancellationPeriod: {
id: number;
isActive: boolean;
@ -1186,6 +1243,7 @@ export declare function updateContract(id: number, data: Partial<ContractCreateD
contractNumber: string;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
bankCardId: number | null;
identityDocumentId: number | null;
salesPlatformId: number | null;
@ -1230,6 +1288,7 @@ export declare function updateContract(id: number, data: Partial<ContractCreateD
contractNumber: string;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
bankCardId: number | null;
identityDocumentId: number | null;
salesPlatformId: number | null;
@ -1269,6 +1328,7 @@ export declare function deleteContract(id: number): Promise<{
contractNumber: string;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
bankCardId: number | null;
identityDocumentId: number | null;
salesPlatformId: number | null;
@ -1424,6 +1484,19 @@ export declare function createFollowUpContract(previousContractId: number): Prom
internetUsername: string | null;
internetPasswordEncrypted: string | null;
}) | null;
billingAddress: {
id: number;
customerId: number;
createdAt: Date;
updatedAt: Date;
type: import(".prisma/client").$Enums.AddressType;
isDefault: boolean;
street: string;
houseNumber: string;
postalCode: string;
city: string;
country: string;
} | null;
} & {
id: number;
customerId: number;
@ -1440,6 +1513,7 @@ export declare function createFollowUpContract(previousContractId: number): Prom
contractNumber: string;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
bankCardId: number | null;
identityDocumentId: number | null;
salesPlatformId: number | null;
@ -1474,5 +1548,38 @@ export declare function getInternetCredentials(contractId: number): Promise<{
export declare function getSipCredentials(phoneNumberId: number): Promise<{
password: string | null;
}>;
export interface ContractTreeNode {
contract: {
id: number;
contractNumber: string;
type: ContractType;
status: ContractStatus;
startDate: Date | null;
endDate: Date | null;
providerName: string | null;
tariffName: string | null;
previousContractId: number | null;
provider?: {
id: number;
name: string;
} | null;
tariff?: {
id: number;
name: string;
} | null;
contractCategory?: {
id: number;
name: string;
} | null;
};
predecessors: ContractTreeNode[];
hasHistory: boolean;
}
/**
* Verträge eines Kunden als Baumstruktur abrufen.
* Wurzelknoten = Verträge ohne Nachfolger (aktuellste Verträge)
* Vorgänger werden rekursiv eingebettet.
*/
export declare function getContractTreeForCustomer(customerId: number): Promise<ContractTreeNode[]>;
export {};
//# sourceMappingURL=contract.service.d.ts.map

File diff suppressed because one or more lines are too long

View File

@ -10,6 +10,7 @@ exports.getContractPassword = getContractPassword;
exports.getSimCardCredentials = getSimCardCredentials;
exports.getInternetCredentials = getInternetCredentials;
exports.getSipCredentials = getSipCredentials;
exports.getContractTreeForCustomer = getContractTreeForCustomer;
const client_1 = require("@prisma/client");
const helpers_js_1 = require("../utils/helpers.js");
const encryption_js_1 = require("../utils/encryption.js");
@ -75,7 +76,7 @@ async function getAllContracts(filters) {
where,
skip,
take,
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
orderBy: [{ createdAt: 'desc' }],
include: {
customer: {
select: {
@ -87,6 +88,7 @@ async function getAllContracts(filters) {
},
},
address: true,
billingAddress: true,
salesPlatform: true,
cancellationPeriod: true,
contractDuration: true,
@ -108,6 +110,7 @@ async function getContractById(id, decryptPassword = false) {
include: {
customer: true,
address: true,
billingAddress: true,
bankCard: true,
identityDocument: true,
salesPlatform: true,
@ -233,6 +236,7 @@ async function createContract(data) {
include: {
customer: true,
address: true,
billingAddress: true,
salesPlatform: true,
energyDetails: true,
internetDetails: { include: { phoneNumbers: true } },
@ -598,4 +602,66 @@ async function getSipCredentials(phoneNumberId) {
return { password: null };
}
}
/**
* Verträge eines Kunden als Baumstruktur abrufen.
* Wurzelknoten = Verträge ohne Nachfolger (aktuellste Verträge)
* Vorgänger werden rekursiv eingebettet.
*/
async function getContractTreeForCustomer(customerId) {
// Alle Verträge des Kunden laden (außer DEACTIVATED)
const allContracts = await prisma.contract.findMany({
where: {
customerId,
status: { not: client_1.ContractStatus.DEACTIVATED },
},
select: {
id: true,
contractNumber: true,
type: true,
status: true,
startDate: true,
endDate: true,
providerName: true,
tariffName: true,
previousContractId: true,
provider: { select: { id: true, name: true } },
tariff: { select: { id: true, name: true } },
contractCategory: { select: { id: true, name: true } },
},
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
});
// Map für schnellen Zugriff: contractId -> contract
const contractMap = new Map(allContracts.map(c => [c.id, c]));
// Set der IDs die als Vorgänger referenziert werden
const predecessorIds = new Set(allContracts
.filter(c => c.previousContractId !== null)
.map(c => c.previousContractId));
// Wurzelverträge = Verträge die keinen Nachfolger haben
// (werden von keinem anderen Vertrag als previousContractId referenziert)
const rootContracts = allContracts.filter(c => !predecessorIds.has(c.id));
// Rekursive Funktion um Vorgängerkette aufzubauen
function buildPredecessorChain(contractId) {
if (contractId === null)
return [];
const contract = contractMap.get(contractId);
if (!contract)
return [];
const predecessors = buildPredecessorChain(contract.previousContractId);
return [{
contract,
predecessors,
hasHistory: predecessors.length > 0,
}];
}
// Baumstruktur für jeden Wurzelvertrag aufbauen
const tree = rootContracts.map(contract => {
const predecessors = buildPredecessorChain(contract.previousContractId);
return {
contract,
predecessors,
hasHistory: predecessors.length > 0,
};
});
return tree;
}
//# sourceMappingURL=contract.service.js.map

File diff suppressed because one or more lines are too long

View File

@ -173,6 +173,7 @@ export declare function getCustomerById(id: number): Promise<({
contractNumber: string;
contractCategoryId: number | null;
addressId: number | null;
billingAddressId: number | null;
bankCardId: number | null;
identityDocumentId: number | null;
salesPlatformId: number | null;

File diff suppressed because one or more lines are too long

View File

@ -124,6 +124,14 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/adm-zip": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",
@ -277,6 +285,15 @@
"@types/node": "*"
}
},
"node_modules/@types/pdfkit": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.4.tgz",
"integrity": "sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@ -580,6 +597,14 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@ -667,6 +692,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -843,6 +876,11 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -876,6 +914,11 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -1163,6 +1206,11 @@
"node": ">= 8.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@ -1185,6 +1233,22 @@
"node": ">= 0.8"
}
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -1513,6 +1577,12 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info."
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@ -1609,6 +1679,23 @@
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@ -1887,6 +1974,11 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
@ -1935,6 +2027,18 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/pdfkit": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
"jpeg-exif": "^1.1.4",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
@ -1977,6 +2081,11 @@
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
},
"node_modules/png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
@ -2140,6 +2249,11 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -2533,6 +2647,11 @@
"node": ">=20"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
},
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
@ -2549,6 +2668,11 @@
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
@ -2617,6 +2741,24 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@ -21,6 +21,7 @@
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^7.0.13",
"pdfkit": "^0.17.2",
"undici": "^6.23.0"
},
"devDependencies": {
@ -34,6 +35,7 @@
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
@ -559,6 +561,14 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/adm-zip": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz",
@ -712,6 +722,15 @@
"@types/node": "*"
}
},
"node_modules/@types/pdfkit": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.4.tgz",
"integrity": "sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@ -1015,6 +1034,14 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@ -1102,6 +1129,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -1278,6 +1313,11 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -1311,6 +1351,11 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -1598,6 +1643,11 @@
"node": ">= 8.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@ -1620,6 +1670,22 @@
"node": ">= 0.8"
}
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -1962,6 +2028,12 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info."
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@ -2058,6 +2130,23 @@
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@ -2336,6 +2425,11 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
@ -2384,6 +2478,18 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/pdfkit": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
"jpeg-exif": "^1.1.4",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
@ -2426,6 +2532,11 @@
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
},
"node_modules/png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
@ -2589,6 +2700,11 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -2982,6 +3098,11 @@
"node": ">=20"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
},
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
@ -2998,6 +3119,11 @@
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
@ -3066,6 +3192,24 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@ -31,6 +31,7 @@
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^7.0.13",
"pdfkit": "^0.17.2",
"undici": "^6.23.0"
},
"devDependencies": {
@ -44,6 +45,7 @@
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"

View File

@ -9,6 +9,7 @@ import { getImapSmtpSettings } from '../services/emailProvider/emailProviderServ
import { decrypt } from '../utils/encryption.js';
import { ApiResponse } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.js';
import { PrismaClient, DocumentType } from '@prisma/client';
import path from 'path';
import fs from 'fs';
@ -1270,3 +1271,250 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
} as ApiResponse);
}
}
// ==================== SAVE EMAIL AS PDF ====================
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
export async function saveEmailAsPdf(req: Request, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const { entityType, entityId, targetKey } = req.body;
console.log('[saveEmailAsPdf] Request:', { emailId, entityType, entityId, targetKey });
// Validierung
if (!entityType || !targetKey) {
res.status(400).json({
success: false,
error: 'entityType und targetKey sind erforderlich',
} as ApiResponse);
return;
}
// E-Mail aus Cache laden (mit Body)
const email = await cachedEmailService.getCachedEmailById(emailId);
if (!email) {
res.status(404).json({
success: false,
error: 'E-Mail nicht gefunden',
} as ApiResponse);
return;
}
// StressfreiEmail laden um an den Kunden zu kommen
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
where: { id: email.stressfreiEmailId },
include: { customer: true },
});
if (!stressfreiEmail) {
res.status(404).json({
success: false,
error: 'E-Mail-Konto nicht gefunden',
} as ApiResponse);
return;
}
// Empfänger-Adressen parsen (JSON Array)
let toAddresses: string[] = [];
let ccAddresses: string[] = [];
try {
toAddresses = JSON.parse(email.toAddresses);
} catch { toAddresses = [email.toAddresses]; }
try {
if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses);
} catch { /* ignore */ }
// PDF generieren
const pdfBuffer = await generateEmailPdf({
from: email.fromAddress,
to: toAddresses.join(', '),
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
subject: email.subject || '(Kein Betreff)',
date: email.receivedAt,
bodyText: email.textBody || undefined,
bodyHtml: email.htmlBody || undefined,
});
// Ziel-Konfiguration finden
let targetConfig;
let targetDir: string;
let targetField: string;
console.log('[saveEmailAsPdf] Looking for target config:', { entityType, targetKey });
if (entityType === 'customer') {
targetConfig = documentTargets.customer.find(t => t.key === targetKey);
} else if (entityType === 'identityDocument') {
targetConfig = documentTargets.identityDocument.find(t => t.key === targetKey);
} else if (entityType === 'bankCard') {
targetConfig = documentTargets.bankCard.find(t => t.key === targetKey);
} else if (entityType === 'contract') {
targetConfig = documentTargets.contract.find(t => t.key === targetKey);
}
console.log('[saveEmailAsPdf] Found targetConfig:', targetConfig);
if (!targetConfig) {
res.status(400).json({
success: false,
error: `Unbekanntes Dokumentziel: ${entityType}/${targetKey}`,
} as ApiResponse);
return;
}
targetDir = targetConfig.directory;
targetField = targetConfig.field;
// Uploads-Verzeichnis erstellen
const uploadsDir = path.join(process.cwd(), 'uploads', targetDir);
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Eindeutigen Dateinamen generieren
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const newFilename = `email-${uniqueSuffix}.pdf`;
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/${targetDir}/${newFilename}`;
// PDF speichern
fs.writeFileSync(filePath, pdfBuffer);
// Alte Datei löschen und DB aktualisieren
if (entityType === 'customer') {
const customer = stressfreiEmail.customer;
// Alte Datei löschen
const oldPath = (customer as any)[targetField];
if (oldPath) {
const oldFullPath = path.join(process.cwd(), oldPath);
if (fs.existsSync(oldFullPath)) {
fs.unlinkSync(oldFullPath);
}
}
await prisma.customer.update({
where: { id: customer.id },
data: { [targetField]: relativePath },
});
} else if (entityType === 'identityDocument') {
if (!entityId) {
fs.unlinkSync(filePath);
res.status(400).json({
success: false,
error: 'entityId ist für identityDocument erforderlich',
} as ApiResponse);
return;
}
const doc = await prisma.identityDocument.findUnique({ where: { id: entityId } });
if (!doc) {
fs.unlinkSync(filePath);
res.status(404).json({
success: false,
error: 'Ausweis nicht gefunden',
} as ApiResponse);
return;
}
// Alte Datei löschen
const oldPath = (doc as any)[targetField];
if (oldPath) {
const oldFullPath = path.join(process.cwd(), oldPath);
if (fs.existsSync(oldFullPath)) {
fs.unlinkSync(oldFullPath);
}
}
await prisma.identityDocument.update({
where: { id: entityId },
data: { [targetField]: relativePath },
});
} else if (entityType === 'bankCard') {
if (!entityId) {
fs.unlinkSync(filePath);
res.status(400).json({
success: false,
error: 'entityId ist für bankCard erforderlich',
} as ApiResponse);
return;
}
const card = await prisma.bankCard.findUnique({ where: { id: entityId } });
if (!card) {
fs.unlinkSync(filePath);
res.status(404).json({
success: false,
error: 'Bankkarte nicht gefunden',
} as ApiResponse);
return;
}
// Alte Datei löschen
const oldPath = (card as any)[targetField];
if (oldPath) {
const oldFullPath = path.join(process.cwd(), oldPath);
if (fs.existsSync(oldFullPath)) {
fs.unlinkSync(oldFullPath);
}
}
await prisma.bankCard.update({
where: { id: entityId },
data: { [targetField]: relativePath },
});
} else if (entityType === 'contract') {
// Contract-ID kommt aus der E-Mail-Zuordnung oder direkt
const contractId = email.contractId;
if (!contractId) {
fs.unlinkSync(filePath);
res.status(400).json({
success: false,
error: 'E-Mail ist keinem Vertrag zugeordnet',
} as ApiResponse);
return;
}
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
if (!contract) {
fs.unlinkSync(filePath);
res.status(404).json({
success: false,
error: 'Vertrag nicht gefunden',
} as ApiResponse);
return;
}
// Alte Datei löschen
const oldPath = (contract as any)[targetField];
if (oldPath) {
const oldFullPath = path.join(process.cwd(), oldPath);
if (fs.existsSync(oldFullPath)) {
fs.unlinkSync(oldFullPath);
}
}
await prisma.contract.update({
where: { id: contractId },
data: { [targetField]: relativePath },
});
}
res.json({
success: true,
data: {
path: relativePath,
filename: newFilename,
size: pdfBuffer.length,
},
} as ApiResponse);
} catch (error) {
console.error('saveEmailAsPdf error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({
success: false,
error: `Fehler beim Erstellen der PDF: ${errorMessage}`,
} as ApiResponse);
}
}

View File

@ -176,6 +176,15 @@ router.post(
cachedEmailController.saveAttachmentTo
);
// E-Mail als PDF exportieren und speichern
// POST /api/emails/:id/save-as-pdf { entityType, entityId?, targetKey }
router.post(
'/emails/:id/save-as-pdf',
authenticate,
requirePermission('customers:update'),
cachedEmailController.saveEmailAsPdf
);
// ==================== VERTRAGSZUORDNUNG ====================
// E-Mail Vertrag zuordnen

View File

@ -1,153 +1,174 @@
import puppeteer from 'puppeteer';
// ==================== PDF SERVICE ====================
/**
* Konvertiert HTML zu PDF mit Puppeteer
*/
export async function htmlToPdf(html: string): Promise<Buffer> {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
import PDFDocument from 'pdfkit';
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({
format: 'A4',
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
printBackground: true,
});
return Buffer.from(pdfBuffer);
} finally {
await browser.close();
}
interface EmailData {
from: string;
to: string;
cc?: string;
subject: string;
date: Date;
bodyText?: string;
bodyHtml?: string;
}
/**
* Baut ein HTML-Dokument für eine E-Mail mit Header
* Generiert ein PDF aus einer E-Mail
*/
export function buildEmailHtml(email: {
subject?: string | null;
fromAddress: string;
fromName?: string | null;
toAddresses: string;
receivedAt: Date;
htmlBody?: string | null;
textBody?: string | null;
}): string {
const formatDate = (date: Date) => {
return new Date(date).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// To-Adressen parsen (JSON Array)
let toList: string[] = [];
export async function generateEmailPdf(email: EmailData): Promise<Buffer> {
return new Promise((resolve, reject) => {
try {
toList = JSON.parse(email.toAddresses);
} catch {
toList = [email.toAddresses];
const doc = new PDFDocument({
size: 'A4',
margins: { top: 50, bottom: 50, left: 50, right: 50 },
});
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Header
doc
.fontSize(18)
.font('Helvetica-Bold')
.text('E-Mail', { align: 'center' });
doc.moveDown(1.5);
// Metadaten-Tabelle
doc.fontSize(10).font('Helvetica');
// Von
doc
.font('Helvetica-Bold')
.text('Von: ', { continued: true })
.font('Helvetica')
.text(email.from);
// An
doc
.font('Helvetica-Bold')
.text('An: ', { continued: true })
.font('Helvetica')
.text(email.to);
// CC (falls vorhanden)
if (email.cc) {
doc
.font('Helvetica-Bold')
.text('CC: ', { continued: true })
.font('Helvetica')
.text(email.cc);
}
const fromDisplay = email.fromName
? `${email.fromName} <${email.fromAddress}>`
: email.fromAddress;
// Datum
const formattedDate = email.date.toLocaleString('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
});
doc
.font('Helvetica-Bold')
.text('Datum: ', { continued: true })
.font('Helvetica')
.text(formattedDate);
const body = email.htmlBody || `<pre style="white-space: pre-wrap; font-family: inherit;">${email.textBody || ''}</pre>`;
// Betreff
doc
.font('Helvetica-Bold')
.text('Betreff: ', { continued: true })
.font('Helvetica')
.text(email.subject || '(Kein Betreff)');
return `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
* {
box-sizing: border-box;
doc.moveDown(1);
// Trennlinie
doc
.moveTo(50, doc.y)
.lineTo(doc.page.width - 50, doc.y)
.stroke();
doc.moveDown(1);
// Inhalt
doc.fontSize(11);
// HTML in Text konvertieren (vereinfacht)
let content = email.bodyText || '';
if (!content && email.bodyHtml) {
// Einfache HTML-zu-Text Konvertierung
content = htmlToPlainText(email.bodyHtml);
}
body {
font-family: Arial, sans-serif;
font-size: 12pt;
line-height: 1.5;
color: #333;
margin: 0;
padding: 0;
if (!content) {
content = '(Kein Inhalt)';
}
.email-header {
background: #f5f5f5;
border-bottom: 2px solid #ddd;
padding: 15px;
margin-bottom: 20px;
// Text mit Zeilenumbrüchen ausgeben
doc.text(content, {
align: 'left',
lineGap: 2,
});
// Footer mit Erstellungsdatum
const footerY = doc.page.height - 40;
doc
.fontSize(8)
.fillColor('#666666')
.text(
`Exportiert am ${new Date().toLocaleString('de-DE')}`,
50,
footerY,
{ align: 'center', width: doc.page.width - 100 }
);
doc.end();
} catch (error) {
reject(error);
}
.email-header h1 {
margin: 0 0 15px 0;
font-size: 16pt;
color: #222;
}
.email-header table {
width: 100%;
border-collapse: collapse;
}
.email-header th {
text-align: left;
width: 60px;
padding: 3px 10px 3px 0;
color: #666;
font-weight: normal;
vertical-align: top;
}
.email-header td {
padding: 3px 0;
}
.email-body {
padding: 0 5px;
}
.email-body img {
max-width: 100%;
height: auto;
}
</style>
</head>
<body>
<div class="email-header">
<h1>${escapeHtml(email.subject || '(Kein Betreff)')}</h1>
<table>
<tr>
<th>Von:</th>
<td>${escapeHtml(fromDisplay)}</td>
</tr>
<tr>
<th>An:</th>
<td>${escapeHtml(toList.join(', '))}</td>
</tr>
<tr>
<th>Datum:</th>
<td>${formatDate(email.receivedAt)}</td>
</tr>
</table>
</div>
<div class="email-body">
${body}
</div>
</body>
</html>
`.trim();
});
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
/**
* Konvertiert HTML in Plain-Text (vereinfacht)
*/
function htmlToPlainText(html: string): string {
let text = html;
// Zeilenumbrüche vor Block-Elementen
text = text.replace(/<(br|p|div|h[1-6]|li|tr)[^>]*>/gi, '\n');
// Listen-Elemente
text = text.replace(/<li[^>]*>/gi, '\n• ');
// Links mit URL
text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)');
// Alle anderen Tags entfernen
text = text.replace(/<[^>]+>/g, '');
// HTML-Entities dekodieren
text = text.replace(/&nbsp;/g, ' ');
text = text.replace(/&amp;/g, '&');
text = text.replace(/&lt;/g, '<');
text = text.replace(/&gt;/g, '>');
text = text.replace(/&quot;/g, '"');
text = text.replace(/&#39;/g, "'");
text = text.replace(/&auml;/g, 'ä');
text = text.replace(/&ouml;/g, 'ö');
text = text.replace(/&uuml;/g, 'ü');
text = text.replace(/&Auml;/g, 'Ä');
text = text.replace(/&Ouml;/g, 'Ö');
text = text.replace(/&Uuml;/g, 'Ü');
text = text.replace(/&szlig;/g, 'ß');
// Mehrfache Leerzeilen reduzieren
text = text.replace(/\n{3,}/g, '\n\n');
// Führende/folgende Leerzeichen entfernen
text = text.trim();
return text;
}

View File

@ -0,0 +1,194 @@
import { useQuery } from '@tanstack/react-query';
import { contractApi } from '../../services/api';
import Modal from '../ui/Modal';
import Badge from '../ui/Badge';
import Card from '../ui/Card';
import CopyButton from '../ui/CopyButton';
import type { ContractType, ContractStatus } from '../../types';
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
GAS: 'Gas',
DSL: 'DSL',
CABLE: 'Kabelinternet',
FIBER: 'Glasfaser',
MOBILE: 'Mobilfunk',
TV: 'TV',
CAR_INSURANCE: 'KFZ-Versicherung',
};
const statusLabels: Record<ContractStatus, string> = {
DRAFT: 'Entwurf',
PENDING: 'Ausstehend',
ACTIVE: 'Aktiv',
CANCELLED: 'Gekündigt',
EXPIRED: 'Abgelaufen',
DEACTIVATED: 'Deaktiviert',
};
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
ACTIVE: 'success',
PENDING: 'warning',
CANCELLED: 'danger',
EXPIRED: 'danger',
DRAFT: 'default',
DEACTIVATED: 'default',
};
interface ContractDetailModalProps {
contractId: number;
isOpen: boolean;
onClose: () => void;
}
export default function ContractDetailModal({ contractId, isOpen, onClose }: ContractDetailModalProps) {
const { data, isLoading, error } = useQuery({
queryKey: ['contract', contractId],
queryFn: () => contractApi.getById(contractId),
enabled: isOpen,
});
const c = data?.data;
return (
<Modal isOpen={isOpen} onClose={onClose} title="Vertragsdetails" size="xl">
{isLoading && (
<div className="text-center py-8 text-gray-500">Laden...</div>
)}
{error && (
<div className="text-center py-8 text-red-600">Fehler beim Laden des Vertrags</div>
)}
{c && (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3 pb-4 border-b">
<span className="text-xl font-bold font-mono flex items-center gap-2">
{c.contractNumber}
<CopyButton value={c.contractNumber} />
</span>
<Badge>{typeLabels[c.type as ContractType] || c.type}</Badge>
<Badge variant={statusVariants[c.status as ContractStatus] || 'default'}>
{statusLabels[c.status as ContractStatus] || c.status}
</Badge>
</div>
{/* Anbieter & Tarif */}
{(c.providerName || c.provider?.name || c.tariffName || c.tariff?.name) && (
<Card title="Anbieter & Tarif">
<div className="grid grid-cols-2 gap-4">
{(c.providerName || c.provider?.name) && (
<div>
<dt className="text-sm text-gray-500">Anbieter</dt>
<dd className="flex items-center gap-1">
{c.providerName || c.provider?.name}
<CopyButton value={c.providerName || c.provider?.name || ''} />
</dd>
</div>
)}
{(c.tariffName || c.tariff?.name) && (
<div>
<dt className="text-sm text-gray-500">Tarif</dt>
<dd className="flex items-center gap-1">
{c.tariffName || c.tariff?.name}
<CopyButton value={c.tariffName || c.tariff?.name || ''} />
</dd>
</div>
)}
{c.customerNumberAtProvider && (
<div>
<dt className="text-sm text-gray-500">Kundennummer beim Anbieter</dt>
<dd className="font-mono flex items-center gap-1">
{c.customerNumberAtProvider}
<CopyButton value={c.customerNumberAtProvider} />
</dd>
</div>
)}
</div>
</Card>
)}
{/* Laufzeit */}
<Card title="Laufzeit">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.startDate && (
<div>
<dt className="text-sm text-gray-500">Vertragsbeginn</dt>
<dd>{new Date(c.startDate).toLocaleDateString('de-DE')}</dd>
</div>
)}
{c.endDate && (
<div>
<dt className="text-sm text-gray-500">Vertragsende</dt>
<dd>{new Date(c.endDate).toLocaleDateString('de-DE')}</dd>
</div>
)}
{c.contractDuration && (
<div>
<dt className="text-sm text-gray-500">Laufzeit</dt>
<dd>{c.contractDuration.description}</dd>
</div>
)}
{c.cancellationPeriod && (
<div>
<dt className="text-sm text-gray-500">Kündigungsfrist</dt>
<dd>{c.cancellationPeriod.description}</dd>
</div>
)}
</div>
</Card>
{/* Portal-Zugangsdaten */}
{(c.portalUsername || c.provider?.portalUrl) && (
<Card title="Portal-Zugangsdaten">
<div className="grid grid-cols-2 gap-4">
{c.provider?.portalUrl && (
<div>
<dt className="text-sm text-gray-500">Portal-URL</dt>
<dd>
<a
href={c.provider.portalUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{c.provider.portalUrl}
</a>
</dd>
</div>
)}
{c.portalUsername && (
<div>
<dt className="text-sm text-gray-500">Benutzername</dt>
<dd className="font-mono flex items-center gap-1">
{c.portalUsername}
<CopyButton value={c.portalUsername} />
</dd>
</div>
)}
</div>
</Card>
)}
{/* Adresse */}
{c.address && (
<Card title="Lieferadresse">
<p>
{c.address.street} {c.address.houseNumber}
</p>
<p>{c.address.postalCode} {c.address.city}</p>
</Card>
)}
{/* Notizen */}
{c.notes && (
<Card title="Notizen">
<p className="whitespace-pre-wrap text-gray-700">{c.notes}</p>
</Card>
)}
</div>
)}
</Modal>
);
}

View File

@ -0,0 +1 @@
export { default as ContractDetailModal } from './ContractDetailModal';

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save } from 'lucide-react';
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
import { CachedEmail, cachedEmailApi } from '../../services/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import Button from '../ui/Button';
@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import toast from 'react-hot-toast';
import SaveAttachmentModal from './SaveAttachmentModal';
import SaveEmailAsPdfModal from './SaveEmailAsPdfModal';
interface EmailDetailProps {
email: CachedEmail;
@ -37,6 +38,7 @@ export default function EmailDetail({
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
const [showPermanentDeleteConfirm, setShowPermanentDeleteConfirm] = useState(false);
const [saveAttachmentFilename, setSaveAttachmentFilename] = useState<string | null>(null);
const [showSaveAsPdfModal, setShowSaveAsPdfModal] = useState(false);
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
@ -219,6 +221,15 @@ export default function EmailDetail({
<Reply className="w-4 h-4 mr-1" />
Antworten
</Button>
{/* E-Mail als PDF speichern */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowSaveAsPdfModal(true)}
title="E-Mail als PDF speichern"
>
<FileDown className="w-4 h-4" />
</Button>
{/* Löschen-Button nur für User mit emails:delete Permission */}
{hasPermission('emails:delete') && (
<Button
@ -481,6 +492,15 @@ export default function EmailDetail({
attachmentFilename={saveAttachmentFilename}
/>
)}
{/* E-Mail als PDF speichern Modal */}
{showSaveAsPdfModal && (
<SaveEmailAsPdfModal
isOpen={true}
onClose={() => setShowSaveAsPdfModal(false)}
emailId={email.id}
/>
)}
</div>
);
}

View File

@ -4,3 +4,4 @@ export { default as ComposeEmailModal } from './ComposeEmailModal';
export { default as AssignToContractModal } from './AssignToContractModal';
export { default as EmailClientTab } from './EmailClientTab';
export { default as ContractEmailsSection } from './ContractEmailsSection';
export { default as SaveEmailAsPdfModal } from './SaveEmailAsPdfModal';

View File

@ -3,6 +3,7 @@ import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal } from '../../components/contracts';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@ -1062,6 +1063,9 @@ export default function ContractDetail() {
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
const [decryptedSipPasswords, setDecryptedSipPasswords] = useState<Record<number, string | null>>({});
// Modal für Vorgängervertrag
const [showPredecessorModal, setShowPredecessorModal] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(contractId),
@ -1270,6 +1274,15 @@ export default function ContractDetail() {
</div>
{!isCustomer && (
<div className="flex gap-2">
{c.previousContract && (
<Button
variant="secondary"
onClick={() => setShowPredecessorModal(true)}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Vorgängervertrag
</Button>
)}
{hasPermission('contracts:create') && !c.followUpContract && (
<Button
variant="secondary"
@ -1320,12 +1333,12 @@ export default function ContractDetail() {
<div>
<dt className="text-sm text-gray-500">Vertragsnummer</dt>
<dd>
<Link
to={`/contracts/${c.previousContract.id}`}
<button
onClick={() => setShowPredecessorModal(true)}
className="text-blue-600 hover:underline"
>
{c.previousContract.contractNumber}
</Link>
</button>
</dd>
</div>
{c.previousContract.providerName && (
@ -2369,6 +2382,15 @@ export default function ContractDetail() {
<p className="whitespace-pre-wrap">{c.notes}</p>
</Card>
)}
{/* Vorgängervertrag Modal */}
{showPredecessorModal && c.previousContract && (
<ContractDetailModal
contractId={c.previousContract.id}
isOpen={true}
onClose={() => setShowPredecessorModal(false)}
/>
)}
</div>
);
}

View File

@ -506,6 +506,14 @@ export const cachedEmailApi = {
);
return res.data;
},
// E-Mail als PDF speichern
saveEmailAsPdf: async (emailId: number, params: { entityType: string; entityId?: number; targetKey: string }) => {
const res = await api.post<ApiResponse<{ path: string; filename: string; size: number }>>(
`/emails/${emailId}/save-as-pdf`,
params
);
return res.data;
},
};
// Contracts - Vertragsbaum für Kundenansicht