feat: add PDF and Word export for expert profile

Professional CV-style document generation using pdfkit (PDF) and docx (Word).
Two-column layout with avatar, contact info, languages on the left and work
experience timeline on the right. Skills rendered as chips. Accent color
configurable (default teal #009688) for later admin customization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-09 11:56:22 +01:00
parent a275cf83e1
commit 2e5a697224
8 changed files with 1214 additions and 7 deletions

View file

@ -23,11 +23,13 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"docx": "^9.6.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfkit": "^0.17.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@ -43,6 +45,7 @@
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.5",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
@ -252,6 +255,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -2376,6 +2380,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
@ -2689,6 +2702,16 @@
"@types/passport": "*" "@types/passport": "*"
} }
}, },
"node_modules/@types/pdfkit": {
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.5.tgz",
"integrity": "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qrcode": { "node_modules/@types/qrcode": {
"version": "1.5.6", "version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
@ -3579,7 +3602,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -3713,6 +3735,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.1", "version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@ -4403,7 +4434,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cors": { "node_modules/cors": {
@ -4500,6 +4530,12 @@
"node": ">= 8" "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==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -4666,6 +4702,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@ -4692,6 +4734,38 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/docx": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.0.tgz",
"integrity": "sha512-y6EaJJMDvt4P7wgGQB9KsZf4wsRkQMJfkc9LlNufRshggI5BT35hGNkXBCAeEoI3MLMwApKguxzjdqqVcBCqNA==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "25.3.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz",
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.5", "version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
@ -5405,7 +5479,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": { "node_modules/fast-diff": {
@ -5610,6 +5683,32 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"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/fontkit/node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -6110,6 +6209,16 @@
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -6223,6 +6332,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -6471,6 +6586,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -7335,6 +7456,13 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"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.",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -7450,6 +7578,48 @@
"npm": ">=6" "npm": ">=6"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@ -7521,6 +7691,34 @@
"integrity": "sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==", "integrity": "sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"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==",
"license": "MIT",
"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==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -7848,6 +8046,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.4", "version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@ -7957,6 +8161,24 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -8296,6 +8518,12 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -8461,6 +8689,19 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/pdfkit": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"license": "MIT",
"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/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@ -8589,6 +8830,11 @@
"node": ">=4" "node": ">=4"
} }
}, },
"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/pngjs": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@ -8693,6 +8939,12 @@
} }
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prompts": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -9113,6 +9365,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -9224,6 +9482,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sax": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/schema-utils": { "node_modules/schema-utils": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@ -9368,6 +9635,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -10021,6 +10294,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"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==",
"license": "MIT"
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@ -10443,6 +10722,32 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT" "license": "MIT"
}, },
"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==",
"license": "MIT",
"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==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/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==",
"license": "MIT"
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@ -10843,6 +11148,24 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -40,11 +40,13 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"docx": "^9.6.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfkit": "^0.17.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@ -60,6 +62,7 @@
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.5",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
@ -78,13 +81,19 @@
"typescript": "^5.6.0" "typescript": "^5.6.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": ["js", "json", "ts"], "moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"collectCoverageFrom": ["**/*.(t|j)s"], "collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node", "testEnvironment": "node",
"moduleNameMapper": { "moduleNameMapper": {

View file

@ -6,13 +6,16 @@ import {
Delete, Delete,
Param, Param,
Body, Body,
Res,
ParseUUIDPipe, ParseUUIDPipe,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ExpertProfileService } from './expert-profile.service'; import { ExpertProfileService } from './expert-profile.service';
import { ProfileExportService } from './profile-export.service';
import { UpdateSkillsDto } from './dto/update-skills.dto'; import { UpdateSkillsDto } from './dto/update-skills.dto';
import { CreateExperienceDto } from './dto/create-experience.dto'; import { CreateExperienceDto } from './dto/create-experience.dto';
import { CreateLanguageDto } from './dto/create-language.dto'; import { CreateLanguageDto } from './dto/create-language.dto';
@ -26,7 +29,10 @@ import { UploadAttachmentDto } from './dto/upload-attachment.dto';
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@Controller('expert-profile') @Controller('expert-profile')
export class ExpertProfileController { export class ExpertProfileController {
constructor(private readonly expertProfileService: ExpertProfileService) {} constructor(
private readonly expertProfileService: ExpertProfileService,
private readonly profileExportService: ProfileExportService,
) {}
// ============================================================ // ============================================================
// Profil // Profil
@ -157,6 +163,39 @@ export class ExpertProfileController {
await this.expertProfileService.deleteCertification(userId, id); await this.expertProfileService.deleteCertification(userId, id);
} }
// ============================================================
// Export (PDF / DOCX)
// ============================================================
@Get('me/export/pdf')
@ApiOperation({ summary: 'Profil als PDF exportieren' })
async exportPdf(
@CurrentUser('sub') userId: string,
@Res() res: Response,
) {
const buffer = await this.profileExportService.generatePdf(userId);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="Profil.pdf"',
'Content-Length': String(buffer.length),
});
res.end(buffer);
}
@Get('me/export/docx')
@ApiOperation({ summary: 'Profil als Word-Dokument exportieren' })
async exportDocx(
@CurrentUser('sub') userId: string,
@Res() res: Response,
) {
const buffer = await this.profileExportService.generateDocx(userId);
res.set({
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': 'attachment; filename="Profil.docx"',
'Content-Length': String(buffer.length),
});
res.end(buffer);
}
// ============================================================ // ============================================================
// Profilanlagen // Profilanlagen
// ============================================================ // ============================================================

View file

@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExpertProfileController } from './expert-profile.controller'; import { ExpertProfileController } from './expert-profile.controller';
import { ExpertProfileService } from './expert-profile.service'; import { ExpertProfileService } from './expert-profile.service';
import { ProfileExportService } from './profile-export.service';
@Module({ @Module({
controllers: [ExpertProfileController], controllers: [ExpertProfileController],
providers: [ExpertProfileService], providers: [ExpertProfileService, ProfileExportService],
exports: [ExpertProfileService], exports: [ExpertProfileService],
}) })
export class ExpertProfileModule {} export class ExpertProfileModule {}

View file

@ -294,6 +294,36 @@ export class ExpertProfileService {
this.logger.log(`Anhang gelöscht: ${attachment.filename}`); this.logger.log(`Anhang gelöscht: ${attachment.filename}`);
} }
// ============================================================
// Export-Daten (User + Profil-Daten komplett)
// ============================================================
async getExportData(userId: string) {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
firstName: true,
lastName: true,
email: true,
phone: true,
mobile: true,
street: true,
postalCode: true,
city: true,
avatar: true,
expertProfile: {
include: {
experiences: { orderBy: { createdAt: 'desc' } },
languages: { orderBy: { language: 'asc' } },
projects: { orderBy: [{ fromYear: 'desc' }, { fromMonth: 'desc' }] },
certifications: { orderBy: { issueYear: 'desc' } },
},
},
},
});
return user;
}
// ============================================================ // ============================================================
// Hilfsfunktion: Ownership-Check // Hilfsfunktion: Ownership-Check
// ============================================================ // ============================================================

View file

@ -0,0 +1,756 @@
import { Injectable, Logger } from '@nestjs/common';
import { ExpertProfileService } from './expert-profile.service';
import PDFDocument from 'pdfkit';
import {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
WidthType,
BorderStyle,
AlignmentType,
ImageRun,
HeadingLevel,
ShadingType,
} from 'docx';
// ============================================================
// Typen für Export-Daten
// ============================================================
interface ExportProject {
fromMonth: number;
fromYear: number;
toMonth: number | null;
toYear: number | null;
isCurrent: boolean;
role: string;
tasks: string | null;
company: string | null;
companySize: string | null;
industry: string | null;
}
interface ExportCertification {
title: string;
issuingBody: string;
website: string | null;
issueYear: number;
}
interface ExportExperience {
area: string;
years: number;
level: string | null;
}
interface ExportLanguage {
language: string;
level: string;
}
interface ExportData {
firstName: string;
lastName: string;
email: string;
phone: string | null;
mobile: string | null;
street: string | null;
postalCode: string | null;
city: string | null;
avatar: string | null;
expertProfile: {
skills: string[];
experiences: ExportExperience[];
languages: ExportLanguage[];
projects: ExportProject[];
certifications: ExportCertification[];
} | null;
}
// ============================================================
// Farb-Hilfsfunktionen
// ============================================================
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) }
: { r: 0, g: 150, b: 136 };
}
function lightenColor(hex: string, factor: number): string {
const { r, g, b } = hexToRgb(hex);
const lr = Math.round(r + (255 - r) * factor);
const lg = Math.round(g + (255 - g) * factor);
const lb = Math.round(b + (255 - b) * factor);
return `#${lr.toString(16).padStart(2, '0')}${lg.toString(16).padStart(2, '0')}${lb.toString(16).padStart(2, '0')}`;
}
// ============================================================
// ProfileExportService
// ============================================================
@Injectable()
export class ProfileExportService {
private readonly logger = new Logger(ProfileExportService.name);
constructor(private readonly expertProfileService: ExpertProfileService) {}
// ============================================================
// PDF Export
// ============================================================
async generatePdf(userId: string, accentColor = '#009688'): Promise<Buffer> {
const data = await this.expertProfileService.getExportData(userId) as ExportData;
const profile = data.expertProfile;
const fullName = `${data.firstName} ${data.lastName}`;
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
const doc = new PDFDocument({
size: 'A4',
margins: { top: 40, bottom: 40, left: 40, right: 40 },
bufferPages: true,
});
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// --- Konstanten ---
const pageWidth = 595.28;
const leftColWidth = 180;
const leftColX = 40;
const rightColX = leftColX + leftColWidth + 20;
const rightColWidth = pageWidth - rightColX - 40;
const pageBottom = 800;
let yLeft = 40;
let yRight = 40;
// --- SEITE 1: Linke Spalte ---
// Avatar (rundes Bild)
if (data.avatar) {
try {
const avatarBuffer = this.base64ToBuffer(data.avatar);
const avatarSize = 110;
const centerX = leftColX + leftColWidth / 2;
const centerY = yLeft + avatarSize / 2;
doc.save();
doc.circle(centerX, centerY, avatarSize / 2).clip();
doc.image(avatarBuffer, centerX - avatarSize / 2, yLeft, {
width: avatarSize,
height: avatarSize,
});
doc.restore();
yLeft += avatarSize + 15;
} catch (err) {
this.logger.warn('Avatar konnte nicht geladen werden', err);
yLeft += 10;
}
}
// Name
doc.font('Helvetica-Bold').fontSize(16).fillColor('#333333');
doc.text(fullName, leftColX, yLeft, { width: leftColWidth, align: 'center' });
yLeft += doc.heightOfString(fullName, { width: leftColWidth }) + 5;
// Rolle (erste Erfahrung als Titel)
if (profile && profile.experiences.length > 0) {
const mainRole = profile.experiences[0].area;
doc.font('Helvetica').fontSize(10).fillColor(accentColor);
doc.text(mainRole, leftColX, yLeft, { width: leftColWidth, align: 'center' });
yLeft += doc.heightOfString(mainRole, { width: leftColWidth }) + 8;
}
// Akzentlinie
doc.moveTo(leftColX + 20, yLeft).lineTo(leftColX + leftColWidth - 20, yLeft)
.strokeColor(accentColor).lineWidth(2).stroke();
yLeft += 15;
// --- KONTAKT ---
yLeft = this.pdfSectionTitle(doc, 'KONTAKT', leftColX, yLeft, leftColWidth, accentColor);
if (data.phone) {
yLeft = this.pdfContactLine(doc, '\u260E', data.phone, leftColX, yLeft, leftColWidth);
}
if (data.mobile) {
yLeft = this.pdfContactLine(doc, '\u260E', data.mobile, leftColX, yLeft, leftColWidth);
}
if (data.email) {
yLeft = this.pdfContactLine(doc, '\u2709', data.email, leftColX, yLeft, leftColWidth);
}
if (data.street || data.city) {
const address = [data.street, [data.postalCode, data.city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
yLeft = this.pdfContactLine(doc, '\u2302', address, leftColX, yLeft, leftColWidth);
}
yLeft += 10;
// --- SPRACHEN ---
if (profile && profile.languages.length > 0) {
yLeft = this.pdfSectionTitle(doc, 'SPRACHEN', leftColX, yLeft, leftColWidth, accentColor);
for (const lang of profile.languages) {
doc.font('Helvetica').fontSize(9).fillColor('#333333');
doc.text(lang.language, leftColX, yLeft, { width: leftColWidth * 0.55, continued: false });
doc.font('Helvetica').fontSize(8).fillColor('#777777');
doc.text(lang.level, leftColX + leftColWidth * 0.6, yLeft, { width: leftColWidth * 0.4 });
yLeft += 14;
}
yLeft += 8;
}
// --- ERFAHRUNG (Expertise-Bereiche) ---
if (profile && profile.experiences.length > 0) {
yLeft = this.pdfSectionTitle(doc, 'ERFAHRUNG', leftColX, yLeft, leftColWidth, accentColor);
for (const exp of profile.experiences) {
doc.font('Helvetica').fontSize(9).fillColor('#333333');
doc.text(exp.area, leftColX, yLeft, { width: leftColWidth * 0.65 });
doc.font('Helvetica').fontSize(8).fillColor('#777777');
const expDetail = `${exp.years}J.${exp.level ? ' · ' + exp.level : ''}`;
doc.text(expDetail, leftColX + leftColWidth * 0.65, yLeft, { width: leftColWidth * 0.35 });
yLeft += 14;
}
}
// --- SEITE 1: Rechte Spalte — BERUFSERFAHRUNG ---
if (profile && profile.projects.length > 0) {
yRight = this.pdfSectionTitle(doc, 'BERUFSERFAHRUNG', rightColX, yRight, rightColWidth, accentColor);
const timelineX = rightColX + 6;
const contentX = rightColX + 18;
const contentWidth = rightColWidth - 18;
for (let i = 0; i < profile.projects.length; i++) {
const proj = profile.projects[i];
// Seitenumbruch prüfen
if (yRight > pageBottom) {
doc.addPage();
yRight = 40;
yRight = this.pdfSectionTitle(doc, 'BERUFSERFAHRUNG (Forts.)', rightColX, yRight, rightColWidth, accentColor);
}
// Timeline-Punkt
doc.circle(timelineX, yRight + 4, 3.5).fill(accentColor);
// Timeline-Linie (bis zum nächsten Eintrag)
if (i < profile.projects.length - 1) {
doc.moveTo(timelineX, yRight + 8).lineTo(timelineX, yRight + 70)
.strokeColor(accentColor).lineWidth(1).stroke();
}
// Zeitraum
const dateRange = this.formatDateRange(proj.fromMonth, proj.fromYear, proj.toMonth, proj.toYear, proj.isCurrent);
doc.font('Helvetica').fontSize(8).fillColor('#888888');
doc.text(dateRange, contentX, yRight, { width: contentWidth });
yRight += 12;
// Rolle
doc.font('Helvetica-Bold').fontSize(10).fillColor(accentColor);
doc.text(proj.role, contentX, yRight, { width: contentWidth });
yRight += doc.heightOfString(proj.role, { width: contentWidth }) + 2;
// Firma + Branche
if (proj.company) {
const companyLine = [proj.company, proj.industry].filter(Boolean).join(' · ');
doc.font('Helvetica').fontSize(9).fillColor('#555555');
doc.text(companyLine, contentX, yRight, { width: contentWidth });
yRight += doc.heightOfString(companyLine, { width: contentWidth }) + 2;
}
// Aufgaben
if (proj.tasks) {
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
doc.font('Helvetica').fontSize(8).fillColor('#444444');
for (const task of taskLines) {
if (yRight > pageBottom) {
doc.addPage();
yRight = 40;
}
const bulletText = `\u2022 ${task.trim()}`;
doc.text(bulletText, contentX + 4, yRight, { width: contentWidth - 8 });
yRight += doc.heightOfString(bulletText, { width: contentWidth - 8 }) + 1;
}
}
yRight += 12;
// Aktualisiere Timeline-Linie (Länge basiert auf tatsächlicher Position)
}
}
// --- FOLGESEITEN: ZERTIFIZIERUNGEN ---
if (profile && profile.certifications.length > 0) {
const certY = Math.max(yLeft, yRight);
let y = certY > pageBottom - 80 ? 40 : certY + 20;
if (certY > pageBottom - 80) {
doc.addPage();
}
y = this.pdfSectionTitle(doc, 'ZERTIFIZIERUNGEN', 40, y, pageWidth - 80, accentColor);
const timelineX = 46;
const contentX = 58;
const contentWidth = pageWidth - 58 - 40;
for (let i = 0; i < profile.certifications.length; i++) {
const cert = profile.certifications[i];
if (y > pageBottom) {
doc.addPage();
y = 40;
}
// Timeline-Punkt
doc.circle(timelineX, y + 4, 3.5).fill(accentColor);
if (i < profile.certifications.length - 1) {
doc.moveTo(timelineX, y + 8).lineTo(timelineX, y + 40)
.strokeColor(accentColor).lineWidth(1).stroke();
}
// Jahr
doc.font('Helvetica').fontSize(8).fillColor('#888888');
doc.text(String(cert.issueYear), contentX, y, { width: contentWidth });
y += 12;
// Titel
doc.font('Helvetica-Bold').fontSize(10).fillColor(accentColor);
doc.text(cert.title, contentX, y, { width: contentWidth });
y += doc.heightOfString(cert.title, { width: contentWidth }) + 2;
// Zertifizierungsstelle
doc.font('Helvetica').fontSize(9).fillColor('#555555');
doc.text(cert.issuingBody, contentX, y, { width: contentWidth });
y += 14;
y += 8;
}
yRight = y;
yLeft = y;
}
// --- FÄHIGKEITEN (Skills als Chips) ---
if (profile && profile.skills.length > 0) {
let y = Math.max(yLeft, yRight);
if (y > pageBottom - 60) {
doc.addPage();
y = 40;
} else {
y += 10;
}
y = this.pdfSectionTitle(doc, 'FÄHIGKEITEN', 40, y, pageWidth - 80, accentColor);
const chipStartX = 40;
const maxX = pageWidth - 40;
let chipX = chipStartX;
const chipHeight = 20;
const chipPadding = 10;
const chipGap = 6;
const lightBg = lightenColor(accentColor, 0.85);
for (const skill of profile.skills) {
doc.font('Helvetica').fontSize(8);
const textWidth = doc.widthOfString(skill);
const chipWidth = textWidth + chipPadding * 2;
if (chipX + chipWidth > maxX) {
chipX = chipStartX;
y += chipHeight + chipGap;
if (y > pageBottom) {
doc.addPage();
y = 40;
}
}
// Chip-Hintergrund (abgerundetes Rechteck)
doc.roundedRect(chipX, y, chipWidth, chipHeight, 10).fill(lightBg);
// Chip-Text
doc.font('Helvetica').fontSize(8).fillColor(accentColor);
doc.text(skill, chipX + chipPadding, y + 5.5, { width: textWidth, lineBreak: false });
chipX += chipWidth + chipGap;
}
}
doc.end();
});
}
// ============================================================
// DOCX Export
// ============================================================
async generateDocx(userId: string, accentColor = '#009688'): Promise<Buffer> {
const data = await this.expertProfileService.getExportData(userId) as ExportData;
const profile = data.expertProfile;
const fullName = `${data.firstName} ${data.lastName}`;
const accentHex = accentColor.replace('#', '');
const lightAccent = lightenColor(accentColor, 0.85).replace('#', '');
const sections: Paragraph[] = [];
// --- Kontakt-Infos für linke Spalte ---
const leftParagraphs: Paragraph[] = [];
// Avatar
let avatarImageRun: ImageRun | null = null;
if (data.avatar) {
try {
const avatarBuffer = this.base64ToBuffer(data.avatar);
avatarImageRun = new ImageRun({
data: avatarBuffer,
transformation: { width: 120, height: 120 },
type: 'png',
});
} catch {
this.logger.warn('Avatar für DOCX konnte nicht geladen werden');
}
}
if (avatarImageRun) {
leftParagraphs.push(
new Paragraph({
children: [avatarImageRun],
alignment: AlignmentType.CENTER,
spacing: { after: 200 },
}),
);
}
// Name
leftParagraphs.push(
new Paragraph({
children: [
new TextRun({
text: fullName,
bold: true,
size: 28,
color: '333333',
}),
],
alignment: AlignmentType.CENTER,
spacing: { after: 100 },
}),
);
// Rolle
if (profile && profile.experiences.length > 0) {
leftParagraphs.push(
new Paragraph({
children: [
new TextRun({
text: profile.experiences[0].area,
size: 20,
color: accentHex,
}),
],
alignment: AlignmentType.CENTER,
spacing: { after: 200 },
}),
);
}
// Kontakt-Sektion
leftParagraphs.push(this.docxSectionHeading('KONTAKT', accentHex));
const contactItems: Array<{ icon: string; text: string }> = [];
if (data.phone) contactItems.push({ icon: '\u260E', text: data.phone });
if (data.mobile) contactItems.push({ icon: '\u260E', text: data.mobile });
if (data.email) contactItems.push({ icon: '\u2709', text: data.email });
if (data.street || data.city) {
const address = [data.street, [data.postalCode, data.city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
contactItems.push({ icon: '\u2302', text: address });
}
for (const item of contactItems) {
leftParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: `${item.icon} `, size: 18, color: accentHex }),
new TextRun({ text: item.text, size: 16, color: '555555' }),
],
spacing: { after: 60 },
}),
);
}
// Sprachen
if (profile && profile.languages.length > 0) {
leftParagraphs.push(this.docxSectionHeading('SPRACHEN', accentHex));
for (const lang of profile.languages) {
leftParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: lang.language, size: 18, color: '333333' }),
new TextRun({ text: ` ${lang.level}`, size: 16, color: '777777' }),
],
spacing: { after: 40 },
}),
);
}
}
// Erfahrung (Expertise-Bereiche)
if (profile && profile.experiences.length > 0) {
leftParagraphs.push(this.docxSectionHeading('ERFAHRUNG', accentHex));
for (const exp of profile.experiences) {
const detail = `${exp.years} J.${exp.level ? ' · ' + exp.level : ''}`;
leftParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: exp.area, size: 18, color: '333333' }),
new TextRun({ text: ` ${detail}`, size: 16, color: '777777' }),
],
spacing: { after: 40 },
}),
);
}
}
// --- Rechte Spalte: Berufserfahrung ---
const rightParagraphs: Paragraph[] = [];
if (profile && profile.projects.length > 0) {
rightParagraphs.push(this.docxSectionHeading('BERUFSERFAHRUNG', accentHex));
for (const proj of profile.projects) {
const dateRange = this.formatDateRange(proj.fromMonth, proj.fromYear, proj.toMonth, proj.toYear, proj.isCurrent);
rightParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: dateRange, size: 16, color: '888888', italics: true }),
],
spacing: { before: 160, after: 40 },
}),
);
rightParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: proj.role, bold: true, size: 20, color: accentHex }),
],
spacing: { after: 30 },
}),
);
if (proj.company) {
const companyLine = [proj.company, proj.industry].filter(Boolean).join(' · ');
rightParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: companyLine, size: 18, color: '555555' }),
],
spacing: { after: 40 },
}),
);
}
if (proj.tasks) {
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
for (const task of taskLines) {
rightParagraphs.push(
new Paragraph({
children: [
new TextRun({ text: `\u2022 ${task.trim()}`, size: 16, color: '444444' }),
],
spacing: { after: 20 },
}),
);
}
}
}
}
// --- Tabelle (Zwei-Spalten-Layout) ---
const noBorders = {
top: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
bottom: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
left: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
right: { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' },
};
const layoutTable = new Table({
rows: [
new TableRow({
children: [
new TableCell({
children: leftParagraphs,
width: { size: 30, type: WidthType.PERCENTAGE },
borders: noBorders,
}),
new TableCell({
children: rightParagraphs.length > 0 ? rightParagraphs : [new Paragraph('')],
width: { size: 70, type: WidthType.PERCENTAGE },
borders: noBorders,
}),
],
}),
],
width: { size: 100, type: WidthType.PERCENTAGE },
});
// --- Volle Breite: Zertifizierungen ---
if (profile && profile.certifications.length > 0) {
sections.push(this.docxSectionHeading('ZERTIFIZIERUNGEN', accentHex));
for (const cert of profile.certifications) {
sections.push(
new Paragraph({
children: [
new TextRun({ text: String(cert.issueYear), size: 16, color: '888888', italics: true }),
],
spacing: { before: 120, after: 30 },
}),
);
sections.push(
new Paragraph({
children: [
new TextRun({ text: cert.title, bold: true, size: 20, color: accentHex }),
],
spacing: { after: 20 },
}),
);
sections.push(
new Paragraph({
children: [
new TextRun({ text: cert.issuingBody, size: 18, color: '555555' }),
],
spacing: { after: 60 },
}),
);
}
}
// --- Volle Breite: Fähigkeiten (Skills als Chips) ---
if (profile && profile.skills.length > 0) {
sections.push(this.docxSectionHeading('FÄHIGKEITEN', accentHex));
// Skills als inline TextRuns mit Shading
const skillRuns: TextRun[] = [];
for (let i = 0; i < profile.skills.length; i++) {
skillRuns.push(
new TextRun({
text: ` ${profile.skills[i]} `,
size: 16,
color: accentHex,
shading: { type: ShadingType.CLEAR, color: 'auto', fill: lightAccent },
}),
);
if (i < profile.skills.length - 1) {
skillRuns.push(new TextRun({ text: ' ', size: 16 }));
}
}
sections.push(
new Paragraph({
children: skillRuns,
spacing: { before: 100, after: 100 },
}),
);
}
// --- Dokument zusammenstellen ---
const document = new Document({
sections: [
{
properties: {
page: {
margin: { top: 720, bottom: 720, left: 720, right: 720 },
},
},
children: [layoutTable, ...sections],
},
],
});
const buffer = await Packer.toBuffer(document);
this.logger.log(`DOCX generiert für User ${userId}: ${buffer.length} Bytes`);
return buffer;
}
// ============================================================
// PDF-Hilfsfunktionen
// ============================================================
private pdfSectionTitle(
doc: PDFKit.PDFDocument,
title: string,
x: number,
y: number,
width: number,
accentColor: string,
): number {
doc.font('Helvetica-Bold').fontSize(10).fillColor('#333333');
doc.text(title, x, y, { width });
y += 14;
doc.moveTo(x, y).lineTo(x + Math.min(width, 60), y)
.strokeColor(accentColor).lineWidth(1.5).stroke();
y += 8;
return y;
}
private pdfContactLine(
doc: PDFKit.PDFDocument,
icon: string,
text: string,
x: number,
y: number,
width: number,
): number {
doc.font('Helvetica').fontSize(8).fillColor('#777777');
doc.text(`${icon} ${text}`, x, y, { width });
return y + doc.heightOfString(`${icon} ${text}`, { width }) + 3;
}
// ============================================================
// DOCX-Hilfsfunktionen
// ============================================================
private docxSectionHeading(title: string, accentHex: string): Paragraph {
return new Paragraph({
children: [
new TextRun({
text: title,
bold: true,
size: 22,
color: '333333',
}),
],
heading: HeadingLevel.HEADING_2,
spacing: { before: 300, after: 80 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 3, color: accentHex, space: 4 },
},
});
}
// ============================================================
// Utility
// ============================================================
private formatDateRange(
fromMonth: number,
fromYear: number,
toMonth: number | null,
toYear: number | null,
isCurrent: boolean,
): string {
const from = `${String(fromMonth).padStart(2, '0')}/${fromYear}`;
if (isCurrent) return `${from} - heute`;
if (toMonth && toYear) return `${from} - ${String(toMonth).padStart(2, '0')}/${toYear}`;
return from;
}
private base64ToBuffer(dataUrl: string): Buffer {
// Entferne Data-URL-Prefix (z.B. "data:image/png;base64,")
const base64Data = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl;
return Buffer.from(base64Data, 'base64');
}
}

View file

@ -4,6 +4,12 @@
gap: 1.5rem; gap: 1.5rem;
} }
.exportBar {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.twoColumnRow { .twoColumnRow {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;

View file

@ -67,6 +67,7 @@ export function ExpertProfileTab() {
const [profile, setProfile] = useState<ExpertProfile | null>(null); const [profile, setProfile] = useState<ExpertProfile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [exporting, setExporting] = useState(false);
const loadProfile = useCallback(async () => { const loadProfile = useCallback(async () => {
try { try {
@ -84,6 +85,28 @@ export function ExpertProfileTab() {
loadProfile(); loadProfile();
}, [loadProfile]); }, [loadProfile]);
const handleExport = async (format: 'pdf' | 'docx') => {
setExporting(true);
try {
const response = await api.get(`/expert-profile/me/export/${format}`, {
responseType: 'blob',
});
const blob = response.data as Blob;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = format === 'pdf' ? 'Profil.pdf' : 'Profil.docx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
setError(`Export als ${format.toUpperCase()} fehlgeschlagen`);
} finally {
setExporting(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className={styles.loading}> <div className={styles.loading}>
@ -100,6 +123,26 @@ export function ExpertProfileTab() {
return ( return (
<div className={styles.expertContainer}> <div className={styles.expertContainer}>
{/* Export-Buttons */}
<div className={styles.exportBar}>
<button
type="button"
className={styles.btnSecondary}
onClick={() => handleExport('pdf')}
disabled={exporting}
>
{exporting ? 'Exportiert...' : 'PDF Export'}
</button>
<button
type="button"
className={styles.btnSecondary}
onClick={() => handleExport('docx')}
disabled={exporting}
>
{exporting ? 'Exportiert...' : 'Word Export'}
</button>
</div>
{/* Skills + Sprachen nebeneinander */} {/* Skills + Sprachen nebeneinander */}
<div className={styles.twoColumnRow}> <div className={styles.twoColumnRow}>
<SkillsSection skills={profile.skills} onUpdate={loadProfile} /> <SkillsSection skills={profile.skills} onUpdate={loadProfile} />