diff --git a/backend/assets/flockpal-text.png b/backend/assets/flockpal-text.png new file mode 100644 index 0000000..3a42159 Binary files /dev/null and b/backend/assets/flockpal-text.png differ diff --git a/backend/package-lock.json b/backend/package-lock.json index 5317c51..10d459b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,7 +17,9 @@ "helmet": "8.1.0", "morgan": "1.10.0", "nodemailer": "^8.0.5", + "pdfkit": "^0.18.0", "pg": "8.13.1", + "qrcode": "^1.5.4", "stripe": "^22.0.2", "zod": "3.24.1" }, @@ -26,7 +28,9 @@ "@types/express": "4.17.21", "@types/morgan": "1.9.9", "@types/node": "22.10.2", + "@types/pdfkit": "^0.17.6", "@types/pg": "8.11.10", + "@types/qrcode": "^1.5.6", "tsx": "4.19.2", "typescript": "5.7.2" } @@ -523,6 +527,39 @@ "win32" ] }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -615,6 +652,16 @@ "@types/node": "*" } }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.11.10", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", @@ -627,6 +674,16 @@ "pg-types": "^4.0.1" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -675,12 +732,56 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -723,6 +824,24 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/bullmq": { "version": "5.76.4", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.4.tgz", @@ -778,6 +897,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "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/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -787,6 +935,24 @@ "node": ">=0.10.0" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -857,6 +1023,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -895,6 +1070,18 @@ "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/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -927,6 +1114,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1082,6 +1275,12 @@ "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", @@ -1100,6 +1299,36 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1142,6 +1371,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1327,6 +1565,52 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, + "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/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -1570,6 +1854,48 @@ "node": ">= 0.8" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1579,12 +1905,35 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.18.0.tgz", + "integrity": "sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, "node_modules/pg": { "version": "8.13.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", @@ -1742,6 +2091,23 @@ "split2": "^4.1.0" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postgres-array": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", @@ -1805,6 +2171,23 @@ "node": ">= 0.10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -1865,6 +2248,21 @@ "node": ">=4" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1875,6 +2273,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1967,6 +2371,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2069,6 +2479,32 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stripe": { "version": "22.0.2", "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.2.tgz", @@ -2086,6 +2522,12 @@ } } }, + "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/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2154,6 +2596,32 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "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/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2181,6 +2649,26 @@ "node": ">= 0.8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -2190,6 +2678,47 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zod": { "version": "3.24.1", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", diff --git a/backend/package.json b/backend/package.json index 7adcb12..414f645 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,7 +23,9 @@ "helmet": "8.1.0", "morgan": "1.10.0", "nodemailer": "^8.0.5", + "pdfkit": "^0.18.0", "pg": "8.13.1", + "qrcode": "^1.5.4", "stripe": "^22.0.2", "zod": "3.24.1" }, @@ -32,7 +34,9 @@ "@types/express": "4.17.21", "@types/morgan": "1.9.9", "@types/node": "22.10.2", + "@types/pdfkit": "^0.17.6", "@types/pg": "8.11.10", + "@types/qrcode": "^1.5.6", "tsx": "4.19.2", "typescript": "5.7.2" } diff --git a/backend/src/app.ts b/backend/src/app.ts index a2307b8..d7e9e3f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -64,6 +64,7 @@ import { updateVetVisitForBird, } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; +import { renderAdoptionReportPdf } from './reports/adoptionReport.js'; import { createAuditLogEntry, createFlockNote, @@ -149,6 +150,7 @@ const trustProxy = process.env.TRUST_PROXY?.trim() ?? ''; const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false'; const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York'; const milestoneReminderCheckIntervalMs = 60 * 60 * 1000; +const adoptionReportWeightHistoryDays = 425; const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy'; if (trustProxy) { @@ -781,6 +783,7 @@ app.disable('x-powered-by'); app.use(helmet({ crossOriginResourcePolicy: false })); app.use( cors({ + exposedHeaders: ['X-FlockPal-Transfer-Code'], origin(origin, callback) { if (!origin || allowedOrigins.includes(origin)) { callback(null, true); @@ -3362,6 +3365,92 @@ app.post( }, ); +app.post( + '/api/birds/:birdId/reports/adoption', + requireAuth, + requireWriteAccess, + requireSessionAuth, + requireWorkspaceRole(['owner', 'assistant']), + async (req: Request, res: Response, next: NextFunction) => { + try { + const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id); + + if (!sourceBird) { + res.status(404).json({ error: 'Bird not found.' }); + return; + } + + if (!ensureBirdWritable(sourceBird, res)) { + return; + } + + let transferCode = null; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + transferCode = await createBirdTransferCode({ + code: createBirdTransferCodeValue(), + birdId: sourceBird.id, + sourceWorkspaceId: req.auth!.workspace.id, + requestedByUserId: req.auth!.user.id, + }); + break; + } catch (error) { + if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) { + continue; + } + + throw error; + } + } + + if (!transferCode) { + throw new Error('Unable to create bird transfer code.'); + } + + const [weights, vetVisits, notes] = await Promise.all([ + listWeightsForBird(sourceBird.id, req.auth!.workspace.id, adoptionReportWeightHistoryDays), + listVetVisitsForBird(sourceBird.id, req.auth!.workspace.id), + listFlockNotes(req.auth!.workspace.id), + ]); + const birdNotes = notes.filter((note) => note.bird_id === sourceBird.id); + const pdf = await renderAdoptionReportPdf({ + bird: sourceBird, + weights, + vetVisits, + notes: birdNotes, + workspace: req.auth!.workspace, + transferCode: transferCode.code, + printFriendly: req.query.printFriendly === 'true', + assets: { + logoPath: path.join(process.cwd(), 'assets', 'flockpal-logo.png'), + wordmarkPath: path.join(process.cwd(), 'assets', 'flockpal-text.png'), + defaultBirdPhotoPath: path.join(process.cwd(), 'assets', 'yoda-default.png'), + }, + }); + + await writeAuditLog(req.auth!, 'bird.adoption_report_created', 'bird', sourceBird.id, sourceBird.name, { + transferCodeId: transferCode.id, + printFriendly: req.query.printFriendly === 'true', + }); + + const safeName = sourceBird.name + .trim() + .replace(/[^a-z0-9]+/gi, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() || 'bird'; + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="flockpal-adoption-report-${safeName}.pdf"`); + res.setHeader('Content-Length', pdf.length.toString()); + res.setHeader('X-FlockPal-Transfer-Code', transferCode.code); + res.send(pdf); + } catch (error) { + next(error); + } + }, +); + app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body); diff --git a/backend/src/reports/adoptionReport.ts b/backend/src/reports/adoptionReport.ts new file mode 100644 index 0000000..d7c8b4b --- /dev/null +++ b/backend/src/reports/adoptionReport.ts @@ -0,0 +1,329 @@ +import fs from 'fs'; +import PDFDocument from 'pdfkit'; +import QRCode from 'qrcode'; + +import type { BirdRow, FlockNoteRow, VetVisitRow, WeightRow, WorkspaceRow } from '../types.js'; + +type AdoptionReportInput = { + bird: BirdRow; + weights: WeightRow[]; + vetVisits: VetVisitRow[]; + notes: FlockNoteRow[]; + workspace: WorkspaceRow; + transferCode: string; + assets: { + logoPath: string; + wordmarkPath: string; + defaultBirdPhotoPath: string; + }; + printFriendly?: boolean; +}; + +const page = { width: 612, height: 792, margin: 42 }; + +const colors = { + ink: '#1f2a2a', + muted: '#5d5f59', + red: '#cb3a35', + green: '#238a5a', + blue: '#2769b3', + border: '#cfe0d5', + panel: '#fbf7ee', + paper: '#fffdf9', +}; + +const formatDate = (value: string | null) => { + if (!value) { + return 'Not recorded'; + } + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format( + new Date(`${value.slice(0, 10)}T00:00:00Z`), + ); +}; + +const formatDateTime = (value: string | null) => { + if (!value) { + return 'Not recorded'; + } + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(value)); +}; + +const formatWeight = (value: string | number | null) => { + const numericValue = value === null ? null : Number(value); + return numericValue && Number.isFinite(numericValue) ? `${numericValue.toFixed(1)} g` : 'Pending'; +}; + +const genderLabel = (value: string) => { + if (value === 'female') { + return 'Female'; + } + if (value === 'male') { + return 'Male'; + } + return 'Unknown'; +}; + +const parseList = (value: string | null) => + (value ?? '') + .split(/\r?\n|,/) + .map((entry) => entry.trim()) + .filter(Boolean); + +const dataUrlToBuffer = (value: string | null) => { + if (!value) { + return null; + } + const match = value.match(/^data:image\/(?:png|jpeg|jpg);base64,(.+)$/); + return match ? Buffer.from(match[1], 'base64') : null; +}; + +const collectPdf = (doc: PDFKit.PDFDocument) => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + }); + +const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, width: number, options: PDFKit.Mixins.TextOptions = {}) => { + doc.text(text, x, y, { width, lineGap: 1.5, ...options }); + return doc.y; +}; + +const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) => { + doc.roundedRect(x, y, width, 43, 6).fillAndStroke(colors.panel, colors.border); + doc.fillColor(colors.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 }); + doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { width: width - 16, ellipsis: true }); +}; + +const drawSectionTitle = (doc: PDFKit.PDFDocument, title: string, y: number) => { + doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(14).text(title, page.margin, y); + doc.moveTo(page.margin, y + 19).lineTo(page.width - page.margin, y + 19).strokeColor(colors.border).lineWidth(1).stroke(); + return y + 27; +}; + +const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], birdColor: string, x: number, y: number, width: number, height: number) => { + const plottedWeights = weights + .slice() + .sort((left, right) => left.recorded_on.localeCompare(right.recorded_on)) + .map((entry) => ({ ...entry, numericWeight: Number(entry.weight_grams) })) + .filter((entry) => Number.isFinite(entry.numericWeight)); + + doc.roundedRect(x, y, width, height, 8).fillAndStroke('#ffffff', colors.border); + + if (plottedWeights.length < 2) { + doc.fillColor(colors.muted).fontSize(10).text('Add more weight records to show a trend graph.', x + 14, y + height / 2 - 6, { + width: width - 28, + align: 'center', + }); + return; + } + + const minWeight = Math.min(...plottedWeights.map((entry) => entry.numericWeight)); + const maxWeight = Math.max(...plottedWeights.map((entry) => entry.numericWeight)); + const weightRange = Math.max(1, maxWeight - minWeight); + const padding = 24; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + const chartColor = /^#[0-9a-fA-F]{6}$/.test(birdColor) ? birdColor : colors.green; + + doc.strokeColor('#d9e6dc').lineWidth(0.8); + for (let index = 0; index < 4; index += 1) { + const gridY = y + padding + (plotHeight / 3) * index; + doc.moveTo(x + padding, gridY).lineTo(x + width - padding, gridY).stroke(); + } + + plottedWeights.forEach((entry, index) => { + const pointX = x + padding + (plotWidth / Math.max(1, plottedWeights.length - 1)) * index; + const pointY = y + padding + plotHeight - ((entry.numericWeight - minWeight) / weightRange) * plotHeight; + if (index === 0) { + doc.moveTo(pointX, pointY); + } else { + doc.lineTo(pointX, pointY); + } + }); + doc.strokeColor(chartColor).lineWidth(2).stroke(); + + plottedWeights.forEach((entry, index) => { + const pointX = x + padding + (plotWidth / Math.max(1, plottedWeights.length - 1)) * index; + const pointY = y + padding + plotHeight - ((entry.numericWeight - minWeight) / weightRange) * plotHeight; + doc.circle(pointX, pointY, 2.6).fillAndStroke(chartColor, '#ffffff'); + }); +}; + +const drawTable = (doc: PDFKit.PDFDocument, headers: string[], rows: string[][], x: number, y: number, widths: number[], rowHeight = 28) => { + doc.font('Helvetica-Bold').fontSize(8).fillColor(colors.muted); + headers.forEach((header, index) => { + doc.text(header.toUpperCase(), x + widths.slice(0, index).reduce((sum, value) => sum + value, 0), y, { width: widths[index] - 8 }); + }); + y += 15; + doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke(); + + doc.font('Helvetica').fontSize(8.5).fillColor(colors.ink); + rows.forEach((row) => { + if (y + rowHeight > page.height - page.margin) { + doc.addPage(); + y = page.margin; + } + row.forEach((value, index) => { + doc.text(value, x + widths.slice(0, index).reduce((sum, columnWidth) => sum + columnWidth, 0), y, { + width: widths[index] - 8, + height: rowHeight - 6, + ellipsis: true, + }); + }); + y += rowHeight; + doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke(); + }); + + return y + 6; +}; + +export const renderAdoptionReportPdf = async ({ + bird, + weights, + vetVisits, + notes, + workspace, + transferCode, + assets, + printFriendly = false, +}: AdoptionReportInput) => { + const doc = new PDFDocument({ + size: 'LETTER', + margin: page.margin, + info: { Title: `FlockPal Adoption Report - ${bird.name}`, Author: 'FlockPal', Subject: `Adoption report for ${bird.name}` }, + }); + const output = collectPdf(doc); + + if (!printFriendly) { + doc.rect(0, 0, page.width, page.height).fill(colors.paper); + } + + const logoPath = fs.existsSync(assets.logoPath) ? assets.logoPath : null; + const wordmarkPath = fs.existsSync(assets.wordmarkPath) ? assets.wordmarkPath : logoPath; + const defaultPhotoPath = fs.existsSync(assets.defaultBirdPhotoPath) ? assets.defaultBirdPhotoPath : null; + const photoBuffer = dataUrlToBuffer(bird.photo_data_url); + const contentWidth = page.width - page.margin * 2; + const headerY = page.margin; + const headerHeight = 120; + + doc.roundedRect(page.margin, headerY, contentWidth, headerHeight, 12).fillAndStroke(printFriendly ? '#ffffff' : '#f8f4e8', colors.border); + if (logoPath) { + doc.image(logoPath, page.margin + 10, headerY + 18, { fit: [92, 84], align: 'center', valign: 'center' }); + } + + const photoX = page.margin + 235; + const photoY = headerY + 13; + if (photoBuffer) { + doc.image(photoBuffer, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' }); + } else if (defaultPhotoPath) { + doc.image(defaultPhotoPath, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' }); + } + doc.roundedRect(photoX, photoY, 58, 58, 10).strokeColor('#ffffff').lineWidth(2).stroke(); + doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(22).text(bird.name, page.margin + 140, headerY + 75, { width: 250, align: 'center' }); + doc.fillColor(colors.muted).font('Helvetica').fontSize(9).text('Adoption Report', page.margin + 140, headerY + 98, { width: 250, align: 'center' }); + + const qrDataUrl = await QRCode.toDataURL(transferCode, { margin: 1, width: 96, errorCorrectionLevel: 'H' }); + const qrBuffer = dataUrlToBuffer(qrDataUrl); + const qrX = page.width - page.margin - 110; + doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(8).text('JOIN', qrX, headerY + 6, { width: 96, align: 'center' }); + if (wordmarkPath) { + doc.image(wordmarkPath, qrX - 7, headerY + 12, { fit: [110, 42], align: 'center', valign: 'center' }); + } + if (qrBuffer) { + doc.image(qrBuffer, qrX + 18, headerY + 50, { width: 60 }); + } + doc.fillColor(colors.ink).font('Helvetica').fontSize(7).text(transferCode, qrX - 8, headerY + 111, { width: 112, align: 'center' }); + + let y = headerY + headerHeight + 24; + y = drawSectionTitle(doc, 'Flock Member Info', y); + const factGap = 8; + const factWidth = (contentWidth - factGap) / 2; + const facts = [ + ['Name', bird.name], + ['Species', bird.species], + ['Band/tag ID', bird.tag_id || 'Not recorded'], + ['Sex', genderLabel(bird.gender)], + ['Hatch day', formatDate(bird.date_of_birth)], + ['Favorite snack', bird.favorite_snack || 'Not recorded'], + ['Latest weight', bird.latest_weight_grams ? `${formatWeight(bird.latest_weight_grams)}${bird.latest_recorded_on ? ` on ${formatDate(bird.latest_recorded_on)}` : ''}` : 'Pending'], + ['Source flock', workspace.name], + ]; + facts.forEach(([label, value], index) => { + drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth); + }); + y += Math.ceil(facts.length / 2) * 50 + 8; + + const motivators = parseList(bird.motivators); + const demotivators = parseList(bird.demotivators); + doc.fillColor(colors.blue).font('Helvetica-Bold').fontSize(10).text('Motivators', page.margin, y); + fitText(doc, motivators.length ? motivators.join(', ') : 'Not recorded', page.margin, y + 14, factWidth); + doc.fillColor(colors.blue).font('Helvetica-Bold').fontSize(10).text('Demotivators', page.margin + factWidth + factGap, y); + fitText(doc, demotivators.length ? demotivators.join(', ') : 'Not recorded', page.margin + factWidth + factGap, y + 14, factWidth); + y += 52; + + y = drawSectionTitle(doc, 'Weight Graph', y); + drawSimpleWeightChart(doc, weights, bird.chart_color, page.margin, y, contentWidth, 120); + y += 140; + + y = drawSectionTitle(doc, 'Weight History', y); + y = drawTable( + doc, + ['Date', 'Weight', 'Notes'], + weights.length ? weights.map((entry) => [formatDate(entry.recorded_on), formatWeight(entry.weight_grams), entry.notes || '']) : [['No weights recorded.', '', '']], + page.margin, + y, + [95, 70, contentWidth - 165], + 24, + ); + + if (y > 610) { + doc.addPage(); + y = page.margin; + } + y = drawSectionTitle(doc, 'Veterinary Clinic Info', y); + const vetFacts = [ + ['Clinic name', bird.vet_clinic_name || 'Not recorded'], + ['Clinic address', bird.vet_clinic_address || 'Not recorded'], + ['Account #', bird.vet_account_number || 'Not recorded'], + ['Dr. name', bird.vet_doctor_name || 'Not recorded'], + ]; + vetFacts.forEach(([label, value], index) => { + drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth); + }); + y += Math.ceil(vetFacts.length / 2) * 50 + 8; + + y = drawSectionTitle(doc, 'Vet Visit History', y); + y = drawTable( + doc, + ['Date', 'Clinic', 'Reason', 'Notes'], + vetVisits.length ? vetVisits.map((visit) => [formatDate(visit.visited_on), visit.clinic_name, visit.reason, visit.notes || '']) : [['No vet visits recorded.', '', '', '']], + page.margin, + y, + [70, 115, 120, contentWidth - 305], + 28, + ); + + if (notes.length) { + if (y > 635) { + doc.addPage(); + y = page.margin; + } + y = drawSectionTitle(doc, 'Notes', y); + notes.slice(0, 8).forEach((note) => { + if (y > page.height - page.margin - 48) { + doc.addPage(); + y = page.margin; + } + doc.fillColor(colors.muted).font('Helvetica-Bold').fontSize(8).text(formatDateTime(note.updated_at), page.margin, y); + y = fitText(doc, note.body, page.margin, y + 12, contentWidth, { height: 44, ellipsis: true }); + y += 8; + doc.moveTo(page.margin, y).lineTo(page.width - page.margin, y).strokeColor(colors.border).stroke(); + y += 8; + }); + } + + doc.end(); + return output; +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fc27579..69a16b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4342,11 +4342,41 @@ function App() { return; } - const code = selectedBirdAdoptionTransferCode || (await handleCreateAdoptionTransferCode()); - if (code) { - openAdoptionReport(code, reportWindow, printFriendly); - } else { + if (!selectedBird) { reportWindow.close(); + return; + } + + setAdoptionReportError(''); + setCreatingAdoptionReportCode(true); + + try { + const response = await apiFetch( + `/birds/${selectedBird.id}/reports/adoption${printFriendly ? '?printFriendly=true' : ''}`, + authToken, + { method: 'POST' }, + ); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to create adoption report.')); + } + + const transferCode = response.headers.get('X-FlockPal-Transfer-Code'); + if (transferCode) { + setAdoptionTransferCodes((current) => ({ ...current, [selectedBird.id]: transferCode })); + } + + const reportBlob = await response.blob(); + const reportUrl = URL.createObjectURL(reportBlob); + reportWindow.location.href = reportUrl; + window.setTimeout(() => URL.revokeObjectURL(reportUrl), 60_000); + } catch (reportError) { + reportWindow.close(); + const message = reportError instanceof Error ? reportError.message : 'Unable to create adoption report.'; + setAdoptionReportError(message); + setError(message); + } finally { + setCreatingAdoptionReportCode(false); } };