diff --git a/packages/core-service/package-lock.json b/packages/core-service/package-lock.json index da731e0..cf1532b 100644 --- a/packages/core-service/package-lock.json +++ b/packages/core-service/package-lock.json @@ -23,11 +23,13 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", + "docx": "^9.6.0", "helmet": "^8.0.0", "ioredis": "^5.4.1", "otplib": "^12.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdfkit": "^0.17.2", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -43,6 +45,7 @@ "@types/jest": "^29.5.12", "@types/node": "^22.0.0", "@types/passport-jwt": "^4.0.1", + "@types/pdfkit": "^0.17.5", "@types/qrcode": "^1.5.5", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -252,6 +255,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2376,6 +2380,15 @@ "devOptional": true, "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": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -2689,6 +2702,16 @@ "@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": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -3579,7 +3602,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -3713,6 +3735,15 @@ "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": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -4403,7 +4434,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -4500,6 +4530,12 @@ "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4666,6 +4702,12 @@ "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": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -4692,6 +4734,38 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "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": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -5405,7 +5479,6 @@ "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==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -5610,6 +5683,32 @@ "dev": true, "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6110,6 +6209,16 @@ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6223,6 +6332,12 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6471,6 +6586,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7335,6 +7456,13 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7450,6 +7578,48 @@ "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": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", @@ -7521,6 +7691,34 @@ "integrity": "sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==", "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": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7848,6 +8046,12 @@ "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": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -7957,6 +8161,24 @@ "dev": true, "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": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8296,6 +8518,12 @@ "dev": true, "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": { "version": "1.0.1", "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", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -8589,6 +8830,11 @@ "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": { "version": "5.0.0", "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": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -9113,6 +9365,12 @@ "dev": true, "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": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -9224,6 +9482,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "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": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -9368,6 +9635,12 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10021,6 +10294,12 @@ "dev": true, "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -10443,6 +10722,32 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -10843,6 +11148,24 @@ "dev": true, "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/packages/core-service/package.json b/packages/core-service/package.json index cadfd37..499e636 100644 --- a/packages/core-service/package.json +++ b/packages/core-service/package.json @@ -40,11 +40,13 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", + "docx": "^9.6.0", "helmet": "^8.0.0", "ioredis": "^5.4.1", "otplib": "^12.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdfkit": "^0.17.2", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -60,6 +62,7 @@ "@types/jest": "^29.5.12", "@types/node": "^22.0.0", "@types/passport-jwt": "^4.0.1", + "@types/pdfkit": "^0.17.5", "@types/qrcode": "^1.5.5", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -78,13 +81,19 @@ "typescript": "^5.6.0" }, "jest": { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": ["**/*.(t|j)s"], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { diff --git a/packages/core-service/src/core/expert-profile/expert-profile.controller.ts b/packages/core-service/src/core/expert-profile/expert-profile.controller.ts index 9469fa8..5e6c5e3 100644 --- a/packages/core-service/src/core/expert-profile/expert-profile.controller.ts +++ b/packages/core-service/src/core/expert-profile/expert-profile.controller.ts @@ -6,13 +6,16 @@ import { Delete, Param, Body, + Res, ParseUUIDPipe, HttpCode, HttpStatus, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import type { Response } from 'express'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { ExpertProfileService } from './expert-profile.service'; +import { ProfileExportService } from './profile-export.service'; import { UpdateSkillsDto } from './dto/update-skills.dto'; import { CreateExperienceDto } from './dto/create-experience.dto'; import { CreateLanguageDto } from './dto/create-language.dto'; @@ -26,7 +29,10 @@ import { UploadAttachmentDto } from './dto/upload-attachment.dto'; @ApiBearerAuth('access-token') @Controller('expert-profile') export class ExpertProfileController { - constructor(private readonly expertProfileService: ExpertProfileService) {} + constructor( + private readonly expertProfileService: ExpertProfileService, + private readonly profileExportService: ProfileExportService, + ) {} // ============================================================ // Profil @@ -157,6 +163,39 @@ export class ExpertProfileController { 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 // ============================================================ diff --git a/packages/core-service/src/core/expert-profile/expert-profile.module.ts b/packages/core-service/src/core/expert-profile/expert-profile.module.ts index 7d5ec38..0d3106c 100644 --- a/packages/core-service/src/core/expert-profile/expert-profile.module.ts +++ b/packages/core-service/src/core/expert-profile/expert-profile.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { ExpertProfileController } from './expert-profile.controller'; import { ExpertProfileService } from './expert-profile.service'; +import { ProfileExportService } from './profile-export.service'; @Module({ controllers: [ExpertProfileController], - providers: [ExpertProfileService], + providers: [ExpertProfileService, ProfileExportService], exports: [ExpertProfileService], }) export class ExpertProfileModule {} diff --git a/packages/core-service/src/core/expert-profile/expert-profile.service.ts b/packages/core-service/src/core/expert-profile/expert-profile.service.ts index 9b2a9b4..a5a361b 100644 --- a/packages/core-service/src/core/expert-profile/expert-profile.service.ts +++ b/packages/core-service/src/core/expert-profile/expert-profile.service.ts @@ -294,6 +294,36 @@ export class ExpertProfileService { 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 // ============================================================ diff --git a/packages/core-service/src/core/expert-profile/profile-export.service.ts b/packages/core-service/src/core/expert-profile/profile-export.service.ts new file mode 100644 index 0000000..e488c82 --- /dev/null +++ b/packages/core-service/src/core/expert-profile/profile-export.service.ts @@ -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 { + const data = await this.expertProfileService.getExportData(userId) as ExportData; + const profile = data.expertProfile; + const fullName = `${data.firstName} ${data.lastName}`; + + return new Promise((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 { + 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'); + } +} diff --git a/packages/frontend/src/profile/ExpertProfileTab.module.css b/packages/frontend/src/profile/ExpertProfileTab.module.css index c7619ca..a5d22b2 100644 --- a/packages/frontend/src/profile/ExpertProfileTab.module.css +++ b/packages/frontend/src/profile/ExpertProfileTab.module.css @@ -4,6 +4,12 @@ gap: 1.5rem; } +.exportBar { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + .twoColumnRow { display: grid; grid-template-columns: 1fr 1fr; diff --git a/packages/frontend/src/profile/ExpertProfileTab.tsx b/packages/frontend/src/profile/ExpertProfileTab.tsx index 2362463..a8b0569 100644 --- a/packages/frontend/src/profile/ExpertProfileTab.tsx +++ b/packages/frontend/src/profile/ExpertProfileTab.tsx @@ -67,6 +67,7 @@ export function ExpertProfileTab() { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [exporting, setExporting] = useState(false); const loadProfile = useCallback(async () => { try { @@ -84,6 +85,28 @@ export function ExpertProfileTab() { 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) { return (
@@ -100,6 +123,26 @@ export function ExpertProfileTab() { return (
+ {/* Export-Buttons */} +
+ + +
+ {/* Skills + Sprachen nebeneinander */}