diff --git a/backend/package-lock.json b/backend/package-lock.json index 308571d..0237458 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -30,6 +30,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.4", "cookie-parser": "^1.4.7", + "exceljs": "^4.4.0", "express-rate-limit": "^8.3.2", "helmet": "^8.1.0", "ioredis": "^5.11.1", @@ -1490,6 +1491,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -6110,6 +6152,118 @@ ], "license": "MIT" }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/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/archiver-utils/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/archiver-utils/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/archiver-utils/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/archiver/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -6351,6 +6505,28 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-version": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/binary-version/-/binary-version-7.1.0.tgz", @@ -6521,7 +6697,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -6608,7 +6783,6 @@ "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6689,7 +6863,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -6714,7 +6887,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -6732,6 +6904,23 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -6947,6 +7136,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7307,11 +7508,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -7474,6 +7689,31 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -7739,6 +7979,51 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/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/duplexer2/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/duplexer2/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/duplexer2/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/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -7801,6 +8086,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", @@ -8150,6 +8444,36 @@ "bare-events": "^2.7.0" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -8293,6 +8617,19 @@ "node": ">=4" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8741,6 +9078,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -8767,7 +9110,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -8785,6 +9127,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9036,7 +9394,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/handlebars": { @@ -9308,7 +9665,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -10671,6 +11027,54 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/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/lazystream/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/lazystream/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/lazystream/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/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -10717,6 +11121,12 @@ "dev": true, "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/livekit-server-sdk": { "version": "2.15.5", "resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.15.5.tgz", @@ -10787,6 +11197,36 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -10799,12 +11239,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -10823,6 +11282,12 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -10843,6 +11308,18 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -11213,7 +11690,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11226,7 +11702,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11241,6 +11716,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11406,7 +11893,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12419,6 +12905,36 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -12556,6 +13072,40 @@ "dev": true, "license": "ISC" }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -12616,6 +13166,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -13610,6 +14172,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -13671,6 +14242,15 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -14317,6 +14897,60 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/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/unzipper/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/unzipper/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/unzipper/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/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -14832,6 +15466,12 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -14946,6 +15586,62 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } } } } diff --git a/backend/package.json b/backend/package.json index 37ccaf5..08fe00f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -46,6 +46,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.4", "cookie-parser": "^1.4.7", + "exceljs": "^4.4.0", "express-rate-limit": "^8.3.2", "helmet": "^8.1.0", "ioredis": "^5.11.1", diff --git a/backend/src/common/storage/ncp-object-storage.service.ts b/backend/src/common/storage/ncp-object-storage.service.ts index 3feb096..816241e 100644 --- a/backend/src/common/storage/ncp-object-storage.service.ts +++ b/backend/src/common/storage/ncp-object-storage.service.ts @@ -123,6 +123,25 @@ export class NcpObjectStorageService extends StorageService { return getSignedUrl(client, command, { expiresIn }); } + async getObjectBuffer(key: string): Promise { + const client = this.ensureConfigured(); + try { + const res = await client.send( + new GetObjectCommand({ Bucket: this.bucketName, Key: key }), + ); + const body = res.Body as + | { transformToByteArray?: () => Promise } + | undefined; + // AWS SDK v3 의 스트림 → 바이트 배열(transformToByteArray)로 수집 + if (!body?.transformToByteArray) return null; + const bytes = await body.transformToByteArray(); + return Buffer.from(bytes); + } catch (err: unknown) { + if (this.isNotFound(err)) return null; + throw err; + } + } + async headObject(key: string): Promise { const client = this.ensureConfigured(); try { diff --git a/backend/src/common/storage/storage.service.ts b/backend/src/common/storage/storage.service.ts index 5f3c2ee..2b89eec 100644 --- a/backend/src/common/storage/storage.service.ts +++ b/backend/src/common/storage/storage.service.ts @@ -21,6 +21,9 @@ export abstract class StorageService { // 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용. abstract headObject(key: string): Promise; + // 객체 바이트 다운로드 — 서버 측 텍스트 추출(AI 검토) 등에 사용. 미존재 시 null. + abstract getObjectBuffer(key: string): Promise; + // 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과. abstract deleteObject(key: string): Promise; } diff --git a/backend/src/modules/drive/entities/drive-file.entity.ts b/backend/src/modules/drive/entities/drive-file.entity.ts index 835d6e1..61fb7f9 100644 --- a/backend/src/modules/drive/entities/drive-file.entity.ts +++ b/backend/src/modules/drive/entities/drive-file.entity.ts @@ -18,6 +18,10 @@ import type { AttachmentKind } from '../../task/entities/attachment.entity'; // pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과 export type DriveFileStatus = 'pending' | 'active'; +// AI 텍스트 추출 상태(프로젝트 연동 문서의 AI 검토용 본문 캐시) +// pending: 미추출 / done: 추출 완료 / unsupported: 추출 불가 형식 / failed: 추출 시도 실패 +export type DriveExtractStatus = 'pending' | 'done' | 'unsupported' | 'failed'; + // 드라이브 파일 — 실제 바이너리는 오브젝트 스토리지에 저장하고 메타데이터만 보관. // 업로드는 presigned URL 직접 전송 후 complete 로 확정한다. @Entity('drive_files') @@ -67,6 +71,19 @@ export class DriveFile { @Column({ type: 'varchar', default: 'pending' }) status!: DriveFileStatus; + // AI 검토용 추출 본문(프로젝트 연동 시 1회 추출·캐싱) — 본문은 응답에 노출하지 않음 + @Column({ + name: 'extracted_text', + type: 'text', + nullable: true, + select: false, + }) + extractedText!: string | null; + + // AI 추출 상태 — 연동 문서의 AI 반영 배지 산출 + 재추출 방지에 사용 + @Column({ name: 'extract_status', type: 'varchar', default: 'pending' }) + extractStatus!: DriveExtractStatus; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/backend/src/modules/project/dto/link-drive-folder.dto.ts b/backend/src/modules/project/dto/link-drive-folder.dto.ts new file mode 100644 index 0000000..eabe2d2 --- /dev/null +++ b/backend/src/modules/project/dto/link-drive-folder.dto.ts @@ -0,0 +1,19 @@ +import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import type { DriveLinkPermission } from '../../drive/entities/project-folder-link.entity'; + +// 프로젝트에 드라이브 폴더를 연동하는 요청 +export class LinkDriveFolderDto { + @ApiProperty({ description: '연동할 드라이브 폴더 ID', format: 'uuid' }) + @IsUUID('4', { message: '올바른 폴더 ID가 아닙니다.' }) + folderId!: string; + + @ApiPropertyOptional({ + description: "연동 권한 ('read' 보기/다운로드 | 'read-write' 멤버 업로드 허용)", + default: 'read', + enum: ['read', 'read-write'], + }) + @IsOptional() + @IsIn(['read', 'read-write'], { message: '올바른 권한 값이 아닙니다.' }) + permission?: DriveLinkPermission; +} diff --git a/backend/src/modules/project/entities/project-attachment.entity.ts b/backend/src/modules/project/entities/project-attachment.entity.ts deleted file mode 100644 index 2404f79..0000000 --- a/backend/src/modules/project/entities/project-attachment.entity.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - Index, - JoinColumn, - ManyToOne, - PrimaryGeneratedColumn, - type Relation, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; -import { Project } from './project.entity'; -import type { AttachmentKind } from '../../task/entities/attachment.entity'; - -// 프로젝트 문서 첨부 — 실제 바이너리는 서버 업로드 폴더에 저장하고 메타데이터만 보관. -// (업무 첨부 Attachment 와 동일 패턴 / 동일 업로드 설정 재사용) -@Entity('project_attachments') -export class ProjectAttachment { - @PrimaryGeneratedColumn('uuid') - id!: string; - - // 소속 프로젝트 (프로젝트 삭제 시 함께 삭제) - @Index() - @ManyToOne(() => Project, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'project_id' }) - project!: Relation; - - // 업로더 (사용자 삭제 시에도 첨부는 유지 — SET NULL) - @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) - @JoinColumn({ name: 'uploader_id' }) - uploader!: User | null; - - // 원본 파일명(다운로드 시 표시) - @Column() - name!: string; - - // 디스크 저장명(업로드 폴더 내 고유 파일명) - @Column({ name: 'stored_name' }) - storedName!: string; - - // 파일 크기(바이트) - @Column({ type: 'int' }) - size!: number; - - // MIME 타입 - @Column() - mime!: string; - - // 아이콘 종류(MIME/확장자에서 파생) - @Column({ type: 'varchar' }) - kind!: AttachmentKind; - - // AI 추천용 추출 본문 — 업로드 시 1회 추출·캐싱(매 호출 재파싱 방지). - // 추출 불가 형식/실패 시 null. 용량이 크므로 기본 조회에서는 제외(select:false). - @Column({ - name: 'extracted_text', - type: 'text', - nullable: true, - select: false, - }) - extractedText!: string | null; - - @CreateDateColumn({ name: 'created_at' }) - createdAt!: Date; -} diff --git a/backend/src/modules/project/extract-text.ts b/backend/src/modules/project/extract-text.ts index f0093c7..b4f1a7a 100644 --- a/backend/src/modules/project/extract-text.ts +++ b/backend/src/modules/project/extract-text.ts @@ -1,6 +1,7 @@ import { readFile } from 'fs/promises'; import { PDFParse } from 'pdf-parse'; import { extractRawText } from 'mammoth'; +import { Workbook } from 'exceljs'; // 문서 1건당 추출 텍스트 상한(자) — DB 용량과 AI 토큰 사용량을 함께 보호한다. export const PER_DOC_TEXT_LIMIT = 8000; @@ -9,7 +10,7 @@ export const PER_DOC_TEXT_LIMIT = 8000; // ready: 추출 성공(반영 가능) / failed: 지원 형식이나 추출 실패 / unsupported: 미지원 형식 export type AttachmentAiStatus = 'ready' | 'failed' | 'unsupported'; -// 텍스트 추출 대상 형식인지 여부(txt·csv·pdf·docx). 미지원/추출실패 배지 구분에 사용한다. +// 텍스트 추출 대상 형식인지 여부(txt·csv·pdf·docx·xlsx). 미지원/추출실패 배지 구분에 사용한다. export function isExtractableType(mime: string, name: string): boolean { const lower = name.toLowerCase(); return ( @@ -21,34 +22,49 @@ export function isExtractableType(mime: string, name: string): boolean { lower.endsWith('.pdf') || mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || - lower.endsWith('.docx') + lower.endsWith('.docx') || + mime === + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + lower.endsWith('.xlsx') ); } // 첨부 문서에서 평문 텍스트를 추출한다(업로드 시 1회). 추출 불가 형식/실패/빈 결과는 null. -// 지원 형식: txt·csv(직접 읽기), pdf(pdf-parse), docx(mammoth). -// 그 외(이미지·xlsx·ppt·압축 등)는 추출하지 않고 null 을 반환한다. +// 디스크 경로 버전 — 버퍼를 읽어 공용 추출기에 위임한다. export async function extractAttachmentText( absPath: string, mime: string, name: string, +): Promise { + try { + const buf = await readFile(absPath); + return extractTextFromBuffer(buf, mime, name); + } catch { + return null; + } +} + +// 버퍼 버전 — 오브젝트 스토리지에서 받은 바이트에서 텍스트를 추출한다(드라이브 연동 문서용). +// 지원 형식: txt·csv(직접 디코드), pdf(pdf-parse), docx(mammoth), xlsx(exceljs). 그 외는 null. +export async function extractTextFromBuffer( + buf: Buffer, + mime: string, + name: string, ): Promise { const lower = name.toLowerCase(); try { - // 1) 평문 텍스트 — 라이브러리 없이 그대로 읽는다 + // 1) 평문 텍스트 if ( mime === 'text/plain' || mime === 'text/csv' || lower.endsWith('.txt') || lower.endsWith('.csv') ) { - const buf = await readFile(absPath); return finalize(buf.toString('utf8')); } - // 2) PDF — pdf-parse v2(PDFParse 클래스)로 페이지 텍스트 추출 + // 2) PDF — pdf-parse v2(PDFParse 클래스) if (mime === 'application/pdf' || lower.endsWith('.pdf')) { - const buf = await readFile(absPath); const parser = new PDFParse({ data: buf }); try { const result = await parser.getText(); @@ -58,19 +74,46 @@ export async function extractAttachmentText( } } - // 3) Word(.docx) — mammoth 로 서식 제거한 본문만 추출 + // 3) Word(.docx) — mammoth (버퍼 입력) if ( mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || lower.endsWith('.docx') ) { - const result = await extractRawText({ path: absPath }); + const result = await extractRawText({ buffer: buf }); return finalize(result.value); } + // 4) Excel(.xlsx) — exceljs 로 시트별 셀 텍스트 추출(행=탭 구분) + if ( + mime === + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + lower.endsWith('.xlsx') + ) { + const wb = new Workbook(); + // exceljs 타입(Buffer)과 Node Buffer 제네릭(ArrayBufferLike) 마찰 회피 — + // load 의 실제 파라미터 타입으로 캐스팅한다. + await wb.xlsx.load(buf as unknown as Parameters[0]); + const lines: string[] = []; + wb.eachSheet((sheet) => { + const sheetLines: string[] = []; + sheet.eachRow({ includeEmpty: false }, (row) => { + const cells: string[] = []; + row.eachCell({ includeEmpty: false }, (cell) => { + const t = (cell.text ?? '').toString().trim(); + if (t) cells.push(t); + }); + if (cells.length) sheetLines.push(cells.join('\t')); + }); + // 내용 있는 시트만 제목과 함께 포함 + if (sheetLines.length) lines.push(`# ${sheet.name}`, ...sheetLines); + }); + return finalize(lines.join('\n')); + } + return null; } catch { - // 추출 실패는 무시한다 — 첨부 자체는 정상 저장되어야 한다. + // 추출 실패는 무시 — 호출부가 상태만 'failed' 로 기록한다. return null; } } diff --git a/backend/src/modules/project/project.controller.ts b/backend/src/modules/project/project.controller.ts index 42edc31..0f66e44 100644 --- a/backend/src/modules/project/project.controller.ts +++ b/backend/src/modules/project/project.controller.ts @@ -12,37 +12,22 @@ import { Patch, Post, Query, - Res, - UploadedFile, UseGuards, - UseInterceptors, } from '@nestjs/common'; -import { - ApiConsumes, - ApiOperation, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; -import { FileInterceptor } from '@nestjs/platform-express'; -import type { Response } from 'express'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; -import { BusinessException } from '../../common/exceptions/business.exception'; import type { PublicUser } from '../user/user.service'; import type { PaginatedResult } from '../../common/pagination/pagination'; import { ProjectService, type ProjectResponse, - type ProjectAttachmentResponse, + type ProjectDriveLinkResponse, + type ProjectDocumentResponse, } from './project.service'; -import { - UPLOAD_DIR, - MAX_FILE_SIZE, - fileMimeFilter, - type UploadedDiskFile, -} from '../task/upload.config'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; +import { LinkDriveFolderDto } from './dto/link-drive-folder.dto'; // 프로젝트 컨트롤러 — 모든 엔드포인트 인증 필요 @ApiTags('Project') @@ -102,7 +87,7 @@ export class ProjectController { @Delete(':projectId') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '프로젝트 삭제 (소유자, 시스템 프로젝트 제외)' }) + @ApiOperation({ summary: '프로젝트 삭제 (소유자)' }) @ApiResponse({ status: 200, description: '삭제 성공' }) @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) async remove( @@ -113,65 +98,50 @@ export class ProjectController { return null; } - /* ===================== 문서 첨부 ===================== */ + /* ===================== 드라이브 연동 폴더 / AI 검토 문서 ===================== */ - @Post(':projectId/files') - @HttpCode(HttpStatus.CREATED) - @UseInterceptors( - FileInterceptor('file', { - dest: UPLOAD_DIR, - limits: { fileSize: MAX_FILE_SIZE }, - fileFilter: fileMimeFilter, - }), - ) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: '프로젝트 문서 업로드 (admin)' }) - @ApiResponse({ status: 201, description: '업로드 성공' }) - addFile( + @Get(':projectId/drive-links') + @ApiOperation({ summary: '연동된 드라이브 폴더 목록 (멤버)' }) + @ApiResponse({ status: 200, description: '조회 성공' }) + listDriveLinks( @Param('projectId', ParseUUIDPipe) projectId: string, - @UploadedFile() file: UploadedDiskFile, - @CurrentUser() user: PublicUser, - ): Promise { - if (!file) { - throw new BusinessException( - 'BIZ_001', - '업로드할 수 없는 파일 형식이거나 파일이 없습니다.', - HttpStatus.BAD_REQUEST, - ); - } - return this.projectService.addFile(projectId, user.id, file); + ): Promise { + return this.projectService.listDriveLinks(projectId); } - @Delete(':projectId/files/:fileId') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '프로젝트 문서 삭제 (admin)' }) - @ApiResponse({ status: 200, description: '삭제 성공' }) - async removeFile( + @Post(':projectId/drive-links') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '드라이브 폴더 연동 (admin, 본인 소유 폴더만)' }) + @ApiResponse({ status: 201, description: '연동 성공' }) + @ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' }) + @ApiResponse({ status: 409, description: '이미 연동된 폴더 (BIZ_001)' }) + linkDriveFolder( @Param('projectId', ParseUUIDPipe) projectId: string, - @Param('fileId', ParseUUIDPipe) fileId: string, + @Body() dto: LinkDriveFolderDto, + @CurrentUser() user: PublicUser, + ): Promise { + return this.projectService.linkDriveFolder(projectId, user.id, dto); + } + + @Delete(':projectId/drive-links/:linkId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '드라이브 폴더 연동 해제 (admin)' }) + @ApiResponse({ status: 200, description: '해제 성공' }) + async unlinkDriveFolder( + @Param('projectId', ParseUUIDPipe) projectId: string, + @Param('linkId', ParseUUIDPipe) linkId: string, @CurrentUser() user: PublicUser, ): Promise { - await this.projectService.removeFile(projectId, user.id, fileId); + await this.projectService.unlinkDriveFolder(projectId, user.id, linkId); return null; } - @Get(':projectId/files/:fileId/download') - @ApiOperation({ summary: '프로젝트 문서 다운로드 (멤버)' }) - @ApiResponse({ status: 200, description: '파일 스트림' }) - async downloadFile( + @Get(':projectId/documents') + @ApiOperation({ summary: 'AI 검토 대상 문서 목록 (연동 폴더 내 파일)' }) + @ApiResponse({ status: 200, description: '조회 성공' }) + listDocuments( @Param('projectId', ParseUUIDPipe) projectId: string, - @Param('fileId', ParseUUIDPipe) fileId: string, - @CurrentUser() user: PublicUser, - @Res() res: Response, - ): Promise { - const { attachment, absolutePath } = - await this.projectService.getFileForDownload(projectId, user.id, fileId); - res.setHeader('Content-Type', attachment.mime); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader( - 'Content-Disposition', - `attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`, - ); - res.sendFile(absolutePath); + ): Promise { + return this.projectService.listDocuments(projectId); } } diff --git a/backend/src/modules/project/project.module.ts b/backend/src/modules/project/project.module.ts index 7cf7bb2..8e806b6 100644 --- a/backend/src/modules/project/project.module.ts +++ b/backend/src/modules/project/project.module.ts @@ -3,8 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserModule } from '../user/user.module'; import { Project } from './entities/project.entity'; import { ProjectMember } from './entities/project-member.entity'; -import { ProjectAttachment } from './entities/project-attachment.entity'; import { Task } from '../task/entities/task.entity'; +import { DriveFolder } from '../drive/entities/drive-folder.entity'; +import { DriveFile } from '../drive/entities/drive-file.entity'; +import { ProjectFolderLink } from '../drive/entities/project-folder-link.entity'; import { ActivityModule } from '../activity/activity.module'; import { NotificationModule } from '../notification/notification.module'; import { ProjectService } from './project.service'; @@ -16,7 +18,14 @@ import { ProjectMemberController } from './project-member.controller'; // ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다. @Module({ imports: [ - TypeOrmModule.forFeature([Project, ProjectMember, ProjectAttachment, Task]), + TypeOrmModule.forFeature([ + Project, + ProjectMember, + Task, + DriveFolder, + DriveFile, + ProjectFolderLink, + ]), UserModule, ActivityModule, NotificationModule, diff --git a/backend/src/modules/project/project.service.ts b/backend/src/modules/project/project.service.ts index ebb9e5d..15d157a 100644 --- a/backend/src/modules/project/project.service.ts +++ b/backend/src/modules/project/project.service.ts @@ -1,48 +1,58 @@ import { ForbiddenException, + HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { ILike, Repository, type FindOptionsOrder } from 'typeorm'; -import { unlink } from 'fs/promises'; -import { join } from 'path'; +import { ILike, In, Like, Repository, type FindOptionsOrder } from 'typeorm'; import { UserService, type PublicUser } from '../user/user.service'; import { paginated, resolvePage, type PaginatedResult, } from '../../common/pagination/pagination'; +import { BusinessException } from '../../common/exceptions/business.exception'; +import { StorageService } from '../../common/storage/storage.service'; import { Project } from './entities/project.entity'; import { ProjectMember } from './entities/project-member.entity'; -import { ProjectAttachment } from './entities/project-attachment.entity'; import { Task } from '../task/entities/task.entity'; import type { AttachmentKind } from '../task/entities/attachment.entity'; +import { DriveFolder } from '../drive/entities/drive-folder.entity'; +import { DriveFile } from '../drive/entities/drive-file.entity'; import { - UPLOAD_DIR, - deriveKind, - decodeOriginalName, - type UploadedDiskFile, -} from '../task/upload.config'; + ProjectFolderLink, + type DriveLinkPermission, +} from '../drive/entities/project-folder-link.entity'; import { - extractAttachmentText, + extractTextFromBuffer, isExtractableType, type AttachmentAiStatus, } from './extract-text'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; +import { LinkDriveFolderDto } from './dto/link-drive-folder.dto'; -// 프로젝트 첨부 응답 -export interface ProjectAttachmentResponse { - id: string; +// 문서의 AI 반영 상태 — 'pending'(추출 대기) 추가 +export type DocumentAiStatus = AttachmentAiStatus | 'pending'; + +// 프로젝트에 연동된 드라이브 폴더 응답 +export interface ProjectDriveLinkResponse { + id: string; // 연동(link) ID + folder: { id: string; name: string; path: string }; + permission: DriveLinkPermission; + linkedBy: PublicUser | null; + documentCount: number; // 연동 폴더(하위 포함) 내 활성 파일 수 + createdAt: string; +} + +// AI 검토 대상 문서(연동 폴더 내 드라이브 파일) 응답 — 본문은 노출하지 않음 +export interface ProjectDocumentResponse { + id: string; // 드라이브 파일 ID(다운로드는 드라이브 API 사용) name: string; size: number; kind: AttachmentKind; - url: string; - uploader: PublicUser | null; - createdAt: string; - // AI 추천 반영 상태 — 추출 본문은 노출하지 않고 상태 플래그만 제공 - aiStatus: AttachmentAiStatus; + aiStatus: DocumentAiStatus; } // 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산 @@ -55,10 +65,11 @@ export interface ProjectResponse { doneCount: number; // 완료 업무 수 totalCount: number; // 전체 업무 수 updatedAt: string; - // 단건(findOne)에서만 — 현재 사용자 권한 + 문서 첨부 + // 단건(findOne)에서만 — 현재 사용자 권한 + 연동 폴더/문서 canManage?: boolean; // admin isOwner?: boolean; // 소유자 - attachments?: ProjectAttachmentResponse[]; + driveLinks?: ProjectDriveLinkResponse[]; + documents?: ProjectDocumentResponse[]; } // 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용) @@ -71,8 +82,13 @@ export class ProjectService { private readonly memberRepo: Repository, @InjectRepository(Task) private readonly taskRepo: Repository, - @InjectRepository(ProjectAttachment) - private readonly attachmentRepo: Repository, + @InjectRepository(ProjectFolderLink) + private readonly linkRepo: Repository, + @InjectRepository(DriveFolder) + private readonly folderRepo: Repository, + @InjectRepository(DriveFile) + private readonly fileRepo: Repository, + private readonly storage: StorageService, private readonly userService: UserService, ) {} @@ -125,7 +141,8 @@ export class ProjectService { ...base, canManage: membership?.roleType === 'admin', isOwner: membership?.isOwner ?? false, - attachments: await this.listFiles(project.id), + driveLinks: await this.listDriveLinks(project.id), + documents: await this.listDocuments(project.id), }; } @@ -213,153 +230,226 @@ export class ProjectService { } } - // --- 문서 첨부 --- + // --- 드라이브 연동 폴더 / AI 검토 문서 --- - // 첨부 목록(최신순) — 프로젝트 상세에 포함 - async listFiles(projectId: string): Promise { - // extractedText 는 select:false 이지만, AI 반영 상태 배지 산출을 위해 명시적으로 선택한다. - // (본문은 응답에 담지 않고 상태 판별에만 사용) - const rows = await this.attachmentRepo - .createQueryBuilder('a') - .leftJoinAndSelect('a.uploader', 'uploader') - .addSelect('a.extractedText') - .where('a.project_id = :projectId', { projectId }) - .orderBy('a.created_at', 'DESC') - .getMany(); - return rows.map((a) => this.toAttachmentResponse(a, projectId)); + // 프로젝트에 연동된 드라이브 폴더 목록(최신순) — 프로젝트 상세에 포함 + async listDriveLinks(projectId: string): Promise { + const links = await this.linkRepo.find({ + where: { project: { id: projectId } }, + relations: { folder: true, linkedBy: true }, + order: { createdAt: 'DESC' }, + }); + const out: ProjectDriveLinkResponse[] = []; + for (const l of links) { + out.push( + this.toDriveLinkResponse(l, await this.countFilesInFolderTree(l.folder)), + ); + } + return out; } - // AI 업무 추천용 — 프로젝트 문서들의 캐싱된 추출 본문을 문서명과 함께 합쳐 반환한다. - // 총량 상한으로 잘라 토큰 사용을 보호하며, 추출 텍스트가 없으면 빈 문자열. - // (extractedText 는 select:false 이므로 명시적으로 선택해 조회한다.) + // 드라이브 폴더를 프로젝트에 연동(admin, 본인 소유 폴더만) + async linkDriveFolder( + projectId: string, + userId: string, + dto: LinkDriveFolderDto, + ): Promise { + const project = await this.getProjectOrThrow(projectId); + await this.assertProjectAdmin(project.id, userId); + const folder = await this.folderRepo.findOne({ + where: { id: dto.folderId }, + relations: { owner: true }, + }); + if (!folder) { + throw new NotFoundException(); + } + // 타인 폴더 공유 방지 — 본인이 소유한 폴더만 연동 가능 + if (folder.owner?.id !== userId) { + throw new BusinessException( + 'AUTH_003', + '본인이 소유한 폴더만 연동할 수 있습니다.', + HttpStatus.FORBIDDEN, + ); + } + const existing = await this.linkRepo.findOne({ + where: { project: { id: project.id }, folder: { id: folder.id } }, + }); + if (existing) { + throw new BusinessException( + 'BIZ_001', + '이미 연동된 폴더입니다.', + HttpStatus.CONFLICT, + ); + } + const user = await this.userService.findById(userId); + const saved = await this.linkRepo.save( + this.linkRepo.create({ + project: { id: project.id } as Project, + folder: { id: folder.id } as DriveFolder, + linkedBy: user ?? null, + permission: dto.permission ?? 'read', + }), + ); + return this.toDriveLinkResponse( + { ...saved, folder, linkedBy: user ?? null }, + await this.countFilesInFolderTree(folder), + ); + } + + // 연동 해제(admin) + async unlinkDriveFolder( + projectId: string, + userId: string, + linkId: string, + ): Promise { + const project = await this.getProjectOrThrow(projectId); + await this.assertProjectAdmin(project.id, userId); + const link = await this.linkRepo.findOne({ + where: { id: linkId, project: { id: project.id } }, + }); + if (!link) { + throw new NotFoundException(); + } + await this.linkRepo.remove(link); + } + + // AI 검토 대상 문서 목록 — 연동 폴더(하위 포함) 내 활성 파일 + 추출 상태 + async listDocuments(projectId: string): Promise { + const files = await this.collectLinkedFiles(projectId, false); + return files.map((f) => ({ + id: f.id, + name: f.name, + size: f.size, + kind: f.kind, + aiStatus: this.driveAiStatus(f), + })); + } + + // AI 업무 추천용 — 연동 폴더 내 문서들의 추출 본문을 문서명과 함께 합쳐 반환한다. + // 미추출(pending) 문서는 이 시점에 NCP 에서 받아 추출·캐싱한다(지연 추출). 총량 상한 적용. async getAttachmentsTextForAi(projectId: string): Promise { const TOTAL_LIMIT = 16000; - const rows = await this.attachmentRepo - .createQueryBuilder('a') - .select(['a.name', 'a.extractedText']) - .where('a.project_id = :projectId', { projectId }) - .orderBy('a.created_at', 'ASC') - .getMany(); - + const files = await this.collectLinkedFiles(projectId, true); const parts: string[] = []; let used = 0; - for (const r of rows) { - const text = r.extractedText?.trim(); + for (const f of files) { + let text = f.extractedText?.trim() ?? null; + if (!text && f.extractStatus === 'pending') { + text = await this.extractDriveFile(f); + } if (!text) continue; const remaining = TOTAL_LIMIT - used; if (remaining <= 0) break; const slice = text.length > remaining ? text.slice(0, remaining) : text; - parts.push(`### ${r.name}\n${slice}`); + parts.push(`### ${f.name}\n${slice}`); used += slice.length; } return parts.join('\n\n'); } - // 첨부 업로드 — 관리자만. 디스크 저장은 multer 가 처리, 메타데이터만 영속 - async addFile( + // 연동 폴더(자기+하위 트리) 내 활성 파일 수집. withText=true 면 추출 본문도 선택. + private async collectLinkedFiles( projectId: string, - userId: string, - file: UploadedDiskFile, - ): Promise { - const project = await this.getProjectOrThrow(projectId); - await this.assertProjectAdmin(project.id, userId); - const uploader = await this.userService.findById(userId); - - const name = decodeOriginalName(file.originalname); - // 업로드 시점에 텍스트를 1회 추출해 캐싱 — AI 추천에서 재파싱 없이 꺼내 쓴다. - const extractedText = await extractAttachmentText( - file.path, - file.mimetype, - name, - ); - const saved = await this.attachmentRepo.save( - this.attachmentRepo.create({ - project: { id: project.id } as Project, - uploader: uploader ?? null, - name, - storedName: file.filename, - size: file.size, - mime: file.mimetype, - kind: deriveKind(file.mimetype, name), - extractedText, - }), - ); - return this.toAttachmentResponse( - { ...saved, uploader: uploader ?? null }, - project.id, - ); + withText: boolean, + ): Promise { + const folderIds = await this.collectLinkedFolderIds(projectId); + if (folderIds.length === 0) return []; + const qb = this.fileRepo + .createQueryBuilder('f') + .leftJoin('f.folder', 'fd') + .where('fd.id IN (:...folderIds)', { folderIds }) + .andWhere("f.status = 'active'") + .orderBy('f.name', 'ASC'); + if (withText) qb.addSelect('f.extractedText'); + return qb.getMany(); } - // 첨부 삭제 — 관리자만. 디스크 파일도 정리 - async removeFile( - projectId: string, - userId: string, - fileId: string, - ): Promise { - const project = await this.getProjectOrThrow(projectId); - await this.assertProjectAdmin(project.id, userId); - const attachment = await this.attachmentRepo.findOne({ - where: { id: fileId, project: { id: project.id } }, + // 프로젝트에 연동된 모든 폴더 + 그 하위 폴더 ID 집합(materialized path 기반) + private async collectLinkedFolderIds(projectId: string): Promise { + const links = await this.linkRepo.find({ + where: { project: { id: projectId } }, + relations: { folder: true }, }); - if (!attachment) { - throw new NotFoundException(); + if (links.length === 0) return []; + const ids = new Set(); + for (const l of links) { + const tree = await this.folderRepo.find({ + where: { path: Like(`${l.folder.path}%`) }, + select: { id: true }, + }); + tree.forEach((f) => ids.add(f.id)); + ids.add(l.folder.id); } - await this.attachmentRepo.remove(attachment); - await this.deleteFileFromDisk(attachment.storedName); + return [...ids]; } - // 다운로드용 첨부 메타 + 디스크 절대경로 — 멤버면 조회 가능. 컨트롤러가 스트리밍 - async getFileForDownload( - projectId: string, - userId: string, - fileId: string, - ): Promise<{ attachment: ProjectAttachment; absolutePath: string }> { - const project = await this.getProjectOrThrow(projectId); - await this.assertProjectMember(project.id, userId); - const attachment = await this.attachmentRepo.findOne({ - where: { id: fileId, project: { id: project.id } }, + // 폴더(자기+하위) 내 활성 파일 수 + private async countFilesInFolderTree(folder: DriveFolder): Promise { + const tree = await this.folderRepo.find({ + where: { path: Like(`${folder.path}%`) }, + select: { id: true }, + }); + const ids = tree.map((f) => f.id); + if (folder.id && !ids.includes(folder.id)) ids.push(folder.id); + if (ids.length === 0) return 0; + return this.fileRepo.count({ + where: { folder: { id: In(ids) }, status: 'active' }, }); - if (!attachment) { - throw new NotFoundException(); - } - return { - attachment, - absolutePath: join(UPLOAD_DIR, attachment.storedName), - }; } - // 디스크 파일 삭제(베스트 에포트 — 없어도 무시) - private async deleteFileFromDisk(storedName: string): Promise { + // 드라이브 파일 1건 텍스트 추출(NCP 다운로드 → 추출 → 캐싱). 결과 상태를 함께 기록. + private async extractDriveFile(f: DriveFile): Promise { + if (!isExtractableType(f.mime, f.name)) { + await this.fileRepo.update(f.id, { extractStatus: 'unsupported' }); + return null; + } try { - await unlink(join(UPLOAD_DIR, storedName)); + const buf = await this.storage.getObjectBuffer(f.objectKey); + if (!buf) { + await this.fileRepo.update(f.id, { extractStatus: 'failed' }); + return null; + } + const text = await extractTextFromBuffer(buf, f.mime, f.name); + if (text) { + await this.fileRepo.update(f.id, { + extractedText: text, + extractStatus: 'done', + }); + return text; + } + await this.fileRepo.update(f.id, { extractStatus: 'failed' }); + return null; } catch { - // 이미 없거나 권한 문제 — 본 흐름을 막지 않는다 + await this.fileRepo.update(f.id, { extractStatus: 'failed' }); + return null; } } - private toAttachmentResponse( - att: ProjectAttachment, - projectId: string, - ): ProjectAttachmentResponse { + private toDriveLinkResponse( + link: ProjectFolderLink, + documentCount: number, + ): ProjectDriveLinkResponse { return { - id: att.id, - name: att.name, - size: att.size, - kind: att.kind, - url: `/api/projects/${projectId}/files/${att.id}/download`, - uploader: att.uploader ? UserService.toPublic(att.uploader) : null, - createdAt: att.createdAt.toISOString(), - aiStatus: this.deriveAiStatus(att), + id: link.id, + folder: { + id: link.folder.id, + name: link.folder.name, + path: link.folder.path, + }, + permission: link.permission, + linkedBy: link.linkedBy ? UserService.toPublic(link.linkedBy) : null, + documentCount, + createdAt: link.createdAt.toISOString(), }; } - // 첨부의 AI 반영 상태 산출 — 추출 본문 유무 + 형식으로 판별 - private deriveAiStatus(att: ProjectAttachment): AttachmentAiStatus { - if (att.extractedText && att.extractedText.trim().length > 0) { - return 'ready'; - } - // 본문이 비어 있음 — 지원 형식이면 추출 실패, 아니면 미지원 형식 - return isExtractableType(att.mime, att.name) ? 'failed' : 'unsupported'; + // 드라이브 문서의 AI 반영 상태 — 추출 상태/형식으로 판별 + private driveAiStatus(f: DriveFile): DocumentAiStatus { + if (f.extractStatus === 'done') return 'ready'; + if (f.extractStatus === 'unsupported') return 'unsupported'; + if (f.extractStatus === 'failed') return 'failed'; + // pending — 추출 가능 형식이면 '대기', 아니면 미지원 + return isExtractableType(f.mime, f.name) ? 'pending' : 'unsupported'; } // 프로젝트별 업무 완료/전체 수 — 진행률 집계용 diff --git a/frontend/src/components/common/AttachmentAiBadge.vue b/frontend/src/components/common/AttachmentAiBadge.vue index 0b802ac..52bb3b5 100644 --- a/frontend/src/components/common/AttachmentAiBadge.vue +++ b/frontend/src/components/common/AttachmentAiBadge.vue @@ -1,10 +1,10 @@ + + + + diff --git a/frontend/src/composables/useProject.ts b/frontend/src/composables/useProject.ts index e645088..6b0c6e5 100644 --- a/frontend/src/composables/useProject.ts +++ b/frontend/src/composables/useProject.ts @@ -1,7 +1,8 @@ import { useApi } from './useApi' import type { ApiProject, - ApiProjectAttachment, + ApiProjectDriveLink, + ApiProjectDocument, CreateProjectPayload, ProjectListQuery, UpdateProjectPayload, @@ -42,22 +43,53 @@ export function useProject() { await api.delete(`/projects/${id}`) } - // 문서 첨부 업로드(관리자) — multipart/form-data - async function uploadFile( + // 연동된 드라이브 폴더 목록 + async function listDriveLinks( projectId: string, - file: File, - ): Promise { - const form = new FormData() - form.append('file', file) - return (await api.post(`/projects/${projectId}/files`, form, { - headers: { 'Content-Type': 'multipart/form-data' }, - })) as unknown as ApiProjectAttachment + ): Promise { + return (await api.get( + `/projects/${projectId}/drive-links`, + )) as unknown as ApiProjectDriveLink[] } - // 문서 첨부 삭제(관리자) - async function removeFile(projectId: string, fileId: string): Promise { - await api.delete(`/projects/${projectId}/files/${fileId}`) + // 드라이브 폴더 연동(관리자, 본인 소유 폴더만) + async function linkDriveFolder( + projectId: string, + folderId: string, + permission?: 'read' | 'read-write', + ): Promise { + return (await api.post(`/projects/${projectId}/drive-links`, { + folderId, + ...(permission ? { permission } : {}), + })) as unknown as ApiProjectDriveLink } - return { list, get, create, update, remove, uploadFile, removeFile } + // 드라이브 폴더 연동 해제(관리자) + async function unlinkDriveFolder( + projectId: string, + linkId: string, + ): Promise { + await api.delete(`/projects/${projectId}/drive-links/${linkId}`) + } + + // AI 검토 대상 문서 목록(연동 폴더 내 파일) + async function listDocuments( + projectId: string, + ): Promise { + return (await api.get( + `/projects/${projectId}/documents`, + )) as unknown as ApiProjectDocument[] + } + + return { + list, + get, + create, + update, + remove, + listDriveLinks, + linkDriveFolder, + unlinkDriveFolder, + listDocuments, + } } diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index bd90439..957c79b 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -3,7 +3,7 @@ * (구 목업 정적 데이터는 백엔드 연동 완료로 제거됨 — store/composable 가 실제 응답을 사용) */ -import type { ApiProjectAttachment } from '@/types/project' +import type { ApiProjectDriveLink, ApiProjectDocument } from '@/types/project' /** 업무 상태 */ export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes' @@ -97,8 +97,10 @@ export interface Project { canManage?: boolean /** 소유자 여부 — 프로젝트 삭제 권한 */ isOwner?: boolean - /** 문서 첨부 — 단건 조회에서만 채워짐 */ - attachments?: ApiProjectAttachment[] + /** 연동된 드라이브 폴더 — 단건 조회에서만 채워짐 */ + driveLinks?: ApiProjectDriveLink[] + /** AI 검토 대상 문서(연동 폴더 내 파일) — 단건 조회에서만 채워짐 */ + documents?: ApiProjectDocument[] } /** 프로젝트의 업무 목록 행 */ diff --git a/frontend/src/pages/relay/ProjectCreatePage.vue b/frontend/src/pages/relay/ProjectCreatePage.vue index 696e213..2407dab 100644 --- a/frontend/src/pages/relay/ProjectCreatePage.vue +++ b/frontend/src/pages/relay/ProjectCreatePage.vue @@ -4,46 +4,17 @@ import { ref } from 'vue' import { useRouter, RouterLink } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' import { useProjectStore } from '@/stores/project.store' -import { useProject } from '@/composables/useProject' import { useDialog } from '@/composables/useDialog' -import { formatBytes } from '@/shared/utils/format' const router = useRouter() const projectStore = useProjectStore() -const projectApi = useProject() const dialog = useDialog() const name = ref('') const description = ref('') const submitting = ref(false) -// 첨부 문서 — 생성 전엔 메모리에 보관, 생성 직후 업로드 -const pendingFiles = ref([]) -const fileInput = ref(null) -function triggerFilePicker() { - fileInput.value?.click() -} -function onPickFiles(e: Event) { - const input = e.target as HTMLInputElement - pendingFiles.value.push(...(input.files ? Array.from(input.files) : [])) - input.value = '' -} -function removePending(i: number) { - pendingFiles.value.splice(i, 1) -} -async function uploadPending(projectId: string): Promise { - let failed = 0 - for (const f of pendingFiles.value) { - try { - await projectApi.uploadFile(projectId, f) - } catch { - failed++ - } - } - return failed -} - -// 생성 — 성공 시 첨부 업로드 후 새 프로젝트 상세로 이동 +// 생성 — 성공 시 새 프로젝트 상세로 이동(문서는 설정에서 드라이브 폴더 연동으로 추가) async function createProject() { if (submitting.value) return if (!name.value.trim()) { @@ -56,13 +27,6 @@ async function createProject() { name: name.value.trim(), description: description.value.trim() || undefined, }) - const failed = await uploadPending(project.id) - if (failed > 0) { - void dialog.alert( - `문서 ${failed}개 업로드에 실패했습니다. 프로젝트 설정에서 다시 첨부해 주세요.`, - { variant: 'warning' }, - ) - } await router.push(`/projects/${project.id}`) } catch { // 에러는 API 인터셉터에서 전역 처리 @@ -129,45 +93,14 @@ async function createProject() { /> - +
- 프로젝트 문서 + AI 검토 문서
- 기획서·가이드 등 관련 문서를 첨부하세요. (1개당 최대 25MB) -
-
-
- {{ f.name }} - {{ formatBytes(f.size) }} - -
- - + 프로젝트 생성 후 설정 → AI 검토 문서에서 내 드라이브 폴더를 연동하면, + 그 폴더의 문서가 AI 업무 추천에 활용됩니다.
diff --git a/frontend/src/pages/relay/ProjectDetailPage.vue b/frontend/src/pages/relay/ProjectDetailPage.vue index 5e9ffc7..d0a8e60 100644 --- a/frontend/src/pages/relay/ProjectDetailPage.vue +++ b/frontend/src/pages/relay/ProjectDetailPage.vue @@ -12,11 +12,12 @@ import DropdownSelect from '@/components/DropdownSelect.vue' import SegmentedTabs from '@/components/common/SegmentedTabs.vue' import LoadMore from '@/components/common/LoadMore.vue' import EmptyState from '@/components/common/EmptyState.vue' +import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue' import { useProjectStore } from '@/stores/project.store' import { useTaskStore } from '@/stores/task.store' +import { useDrive } from '@/composables/useDrive' import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task' import { taskStatusLabel, formatBytes } from '@/shared/utils/format' -import { toAbsoluteApiUrl } from '@/composables/useApi' import type { Project } from '@/mock/relay.mock' const route = useRoute() @@ -35,6 +36,18 @@ async function loadProject() { } } +// 연동 문서 다운로드 — 드라이브 단기 presigned URL 발급 후 다운로드 +const driveApi = useDrive() +async function downloadDoc(fileId: string): Promise { + const url = await driveApi.getDownloadUrl(fileId) + const a = document.createElement('a') + a.href = url + a.rel = 'noopener' + document.body.appendChild(a) + a.click() + a.remove() +} + const keyword = ref('') const segment = ref('all') // 세그먼트 탭 옵션(수량 포함) — counts 갱신에 반응 @@ -174,9 +187,9 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length) :can-manage="repo.canManage" /> - +
@@ -188,20 +201,37 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length) stroke-linecap="round" stroke-linejoin="round" > - 프로젝트 문서 {{ repo.attachments.length }} + AI 검토 문서 {{ repo.documents?.length ?? 0 }}
- @@ -518,6 +548,15 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length) flex-wrap: wrap; gap: 0.5rem; } +.pd-sub { + font-size: 0.75rem; + color: var(--text-3); + margin: -0.25rem 0 0.625rem; +} +.pd-empty { + font-size: 0.8125rem; + color: var(--text-3); +} .pd-item { display: inline-flex; align-items: center; @@ -529,6 +568,9 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length) background: #fff; text-decoration: none; color: inherit; + cursor: pointer; + font: inherit; + text-align: left; } .pd-item:hover { background: #fafbfc; diff --git a/frontend/src/pages/relay/ProjectSettingsPage.vue b/frontend/src/pages/relay/ProjectSettingsPage.vue index 4d5b31d..e89847b 100644 --- a/frontend/src/pages/relay/ProjectSettingsPage.vue +++ b/frontend/src/pages/relay/ProjectSettingsPage.vue @@ -5,14 +5,12 @@ import { useRoute, useRouter, RouterLink } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' import ProjectHeader from '@/components/ProjectHeader.vue' import ProjectTabs from '@/components/ProjectTabs.vue' -import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue' +import DriveFolderPicker from '@/components/drive/DriveFolderPicker.vue' import { useProjectStore } from '@/stores/project.store' import { useProject } from '@/composables/useProject' import { useDialog } from '@/composables/useDialog' -import { formatBytes } from '@/shared/utils/format' -import { toAbsoluteApiUrl } from '@/composables/useApi' import { type Project } from '@/mock/relay.mock' -import type { ApiProjectAttachment } from '@/types/project' +import type { ApiProjectDriveLink } from '@/types/project' const route = useRoute() const router = useRouter() @@ -22,10 +20,10 @@ const projectApi = useProject() const dialog = useDialog() const repo = ref(null) -// 첨부는 폼 미저장 입력을 보존하기 위해 별도 ref 로 관리(업/삭제 시 부분 갱신) -const attachments = ref([]) -const fileInput = ref(null) -const fileBusy = ref(false) +// 연동 폴더는 폼 미저장 입력 보존을 위해 별도 ref 로 관리(연동/해제 시 부분 갱신) +const driveLinks = ref([]) +const pickerOpen = ref(false) +const linkBusy = ref(false) // 현재 사용자 권한 — 단건 응답에 포함된 값(백엔드와 동일 정책: 설정=admin, 삭제=소유자) const canManage = computed(() => repo.value?.canManage === true) @@ -56,48 +54,39 @@ async function loadProject() { } displayTitle.value = repo.value.name description.value = repo.value.desc - attachments.value = repo.value.attachments ?? [] + driveLinks.value = repo.value.driveLinks ?? [] } watch(projectId, loadProject, { immediate: true }) -// --- 문서 첨부 (관리자) --- -function triggerFilePicker() { - fileInput.value?.click() -} -async function onPickFiles(e: Event) { - const input = e.target as HTMLInputElement - const files = input.files ? Array.from(input.files) : [] - input.value = '' - if (!files.length || fileBusy.value) return - fileBusy.value = true - let failed = 0 - for (const f of files) { - try { - attachments.value = [ - await projectApi.uploadFile(projectId.value, f), - ...attachments.value, - ] - } catch { - failed++ - } +// --- 드라이브 폴더 연동 (관리자) --- +// 폴더 선택 → 연동 생성. 이미 연동/권한오류 등은 인터셉터가 알림 처리. +async function onPickFolder(folder: { id: string; name: string }) { + pickerOpen.value = false + if (linkBusy.value) return + // 중복 연동 방지(클라이언트 단) + if (driveLinks.value.some((l) => l.folder.id === folder.id)) { + void dialog.alert('이미 연동된 폴더입니다.', { variant: 'warning' }) + return } - fileBusy.value = false - if (failed > 0) { - void dialog.alert(`문서 ${failed}개 업로드에 실패했습니다.`, { - variant: 'warning', - }) + linkBusy.value = true + try { + const link = await projectApi.linkDriveFolder(projectId.value, folder.id) + driveLinks.value = [link, ...driveLinks.value] + } catch { + // 인터셉터 전역 처리 + } finally { + linkBusy.value = false } } -async function removeAttachment(id: string) { - const ok = await dialog.confirm('이 문서를 삭제할까요?', { - title: '문서 삭제', - variant: 'danger', - confirmText: '삭제', - }) +async function removeLink(link: ApiProjectDriveLink) { + const ok = await dialog.confirm( + `'${link.folder.name}' 폴더 연동을 해제할까요? (드라이브의 폴더·파일은 삭제되지 않습니다)`, + { title: '연동 해제', variant: 'danger', confirmText: '해제' }, + ) if (!ok) return try { - await projectApi.removeFile(projectId.value, id) - attachments.value = attachments.value.filter((a) => a.id !== id) + await projectApi.unlinkDriveFolder(projectId.value, link.id) + driveLinks.value = driveLinks.value.filter((l) => l.id !== link.id) } catch { // 인터셉터 전역 처리 } @@ -242,57 +231,60 @@ async function removeProject() {
- +

- 문서 + AI 검토 문서

- 프로젝트 문서 + 드라이브 폴더 연동
- 기획서·가이드 등 관련 문서를 첨부하세요. (1개당 최대 25MB) + 내 드라이브의 폴더를 연동하면, 그 폴더(하위 포함)의 문서가 AI 업무 추천에 활용됩니다. + 연동된 폴더의 문서는 프로젝트 멤버가 열람·다운로드할 수 있습니다.
- 첨부된 문서가 없습니다. + 연동된 폴더가 없습니다.
-
@@ -327,6 +319,13 @@ async function removeProject() { + + + @@ -536,6 +535,20 @@ textarea.tarea { .file-row:hover { background: #fafbfc; } +.fr-ico { + flex: 0 0 auto; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.4375rem; + display: grid; + place-items: center; + color: var(--accent); + background: var(--accent-weak); +} +.fr-ico svg { + width: 1rem; + height: 1rem; +} .fr-name { flex: 1; min-width: 0; diff --git a/frontend/src/stores/project.store.ts b/frontend/src/stores/project.store.ts index 4e95ac5..0432acd 100644 --- a/frontend/src/stores/project.store.ts +++ b/frontend/src/stores/project.store.ts @@ -42,7 +42,8 @@ function toProjectView(api: ApiProject): ProjectView { progressLabel: prog.label, canManage: api.canManage, isOwner: api.isOwner, - attachments: api.attachments, + driveLinks: api.driveLinks, + documents: api.documents, } } diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts index d5c77f7..9e14b21 100644 --- a/frontend/src/types/project.ts +++ b/frontend/src/types/project.ts @@ -2,17 +2,26 @@ import type { ApiMember } from '@/types/repo' -// 프로젝트 문서 첨부(API 원시) — 백엔드 ProjectAttachmentResponse 와 1:1 -export interface ApiProjectAttachment { - id: string +// 문서 AI 반영 상태 — ready: 반영됨 / failed: 추출 실패 / unsupported: 미지원 형식 / pending: 추출 대기 +export type ProjectDocAiStatus = 'ready' | 'failed' | 'unsupported' | 'pending' + +// 프로젝트에 연동된 드라이브 폴더(API 원시) — 백엔드 ProjectDriveLinkResponse 와 1:1 +export interface ApiProjectDriveLink { + id: string // 연동(link) ID + folder: { id: string; name: string; path: string } + permission: 'read' | 'read-write' + linkedBy: { id: string; name: string; avatarUrl: string | null } | null + documentCount: number // 연동 폴더(하위 포함) 내 파일 수 + createdAt: string +} + +// AI 검토 대상 문서(연동 폴더 내 드라이브 파일, API 원시) — 백엔드 ProjectDocumentResponse 와 1:1 +export interface ApiProjectDocument { + id: string // 드라이브 파일 ID(다운로드는 드라이브 API 사용) name: string size: number kind: 'pdf' | 'zip' | 'doc' | 'img' - url: string // 다운로드 경로(/api/...) - uploader: { id: string; name: string; avatarUrl: string | null } | null - createdAt: string - // AI 추천 반영 상태 — ready: 반영 가능 / failed: 추출 실패 / unsupported: 미지원 형식 - aiStatus: 'ready' | 'failed' | 'unsupported' + aiStatus: ProjectDocAiStatus } // 프로젝트(API 원시) — 백엔드 ProjectResponse 와 1:1 @@ -25,10 +34,11 @@ export interface ApiProject { doneCount: number totalCount: number updatedAt: string - // 단건 조회에서만 — 현재 사용자 권한 + 문서 첨부 + // 단건 조회에서만 — 현재 사용자 권한 + 연동 폴더/문서 canManage?: boolean // admin (설정 변경/탭 노출) isOwner?: boolean // 소유자 (프로젝트 삭제) - attachments?: ApiProjectAttachment[] + driveLinks?: ApiProjectDriveLink[] + documents?: ApiProjectDocument[] } // 프로젝트 목록 정렬 — 최근 업데이트순/이름순/최근 생성순 (백엔드 sort 쿼리와 1:1)