Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a92813a31 | |||
| cf1f4db6af | |||
| f023bd0ed7 | |||
| 638d1265f6 | |||
| 9ce3158450 |
Generated
+707
-11
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -123,6 +123,25 @@ export class NcpObjectStorageService extends StorageService {
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
async getObjectBuffer(key: string): Promise<Buffer | null> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
const res = await client.send(
|
||||
new GetObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
const body = res.Body as
|
||||
| { transformToByteArray?: () => Promise<Uint8Array> }
|
||||
| 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<ObjectHead | null> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,9 @@ export abstract class StorageService {
|
||||
// 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용.
|
||||
abstract headObject(key: string): Promise<ObjectHead | null>;
|
||||
|
||||
// 객체 바이트 다운로드 — 서버 측 텍스트 추출(AI 검토) 등에 사용. 미존재 시 null.
|
||||
abstract getObjectBuffer(key: string): Promise<Buffer | null>;
|
||||
|
||||
// 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과.
|
||||
abstract deleteObject(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ const SUGGEST_SYSTEM_PROMPT = [
|
||||
'',
|
||||
'[규칙]',
|
||||
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
|
||||
'- 출력은 완결된 단일 JSON 객체여야 합니다. 모든 객체는 중괄호 {}로 감싸고, 배열 []·문자열 "" 을 정확히 닫으세요. 중간에 끊지 마세요.',
|
||||
'- 카테고리는 정확히 "필수 기능", "관리 기능", "보안 기능", "부가 기능" 4개를 이 순서대로 사용하세요. (다른 이름·추가/누락 카테고리 금지)',
|
||||
'- 각 카테고리에 필요한 항목을 충분히 제안하세요. 항목 수에 제한은 없습니다. 적합한 항목이 없으면 빈 배열([])로 두세요.',
|
||||
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
|
||||
@@ -221,34 +222,46 @@ export class AiService {
|
||||
const raw = await this.callClaude(
|
||||
[{ role: 'user', content: userMsg }],
|
||||
SUGGEST_SYSTEM_PROMPT,
|
||||
4096,
|
||||
8000,
|
||||
);
|
||||
return { groups: this.parseSuggestions(raw) };
|
||||
}
|
||||
|
||||
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
|
||||
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
|
||||
let text = raw
|
||||
const text = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```$/, '')
|
||||
.replace(/```\s*$/, '')
|
||||
.trim();
|
||||
|
||||
// 1) 표준 파싱 — 첫 { ~ 마지막 } 구간만 잘라 JSON.parse
|
||||
let groups: unknown[] = [];
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) text = text.slice(start, end + 1);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`추천 JSON 파싱 실패(응답 잘림 가능) — 원본 ${raw.length}자: ${raw.slice(0, 300)}`,
|
||||
);
|
||||
throw this.suggestFailed();
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
const parsed = JSON.parse(text.slice(start, end + 1)) as {
|
||||
groups?: unknown;
|
||||
};
|
||||
if (Array.isArray(parsed.groups)) groups = parsed.groups;
|
||||
} catch {
|
||||
// 표준 파싱 실패 → 아래 견고 파싱으로 폴백
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 견고 파싱(폴백) — 모델이 깨진/잘린 JSON(누락 중괄호 등)을 내도
|
||||
// "category"/"items" 패턴을 정규식으로 직접 복구한다.
|
||||
if (groups.length === 0) {
|
||||
const recovered = this.recoverGroups(text);
|
||||
if (recovered.length > 0) {
|
||||
groups = recovered;
|
||||
this.logger.warn(
|
||||
`추천 JSON 표준 파싱 실패 → 견고 파싱으로 복구(${recovered.length}그룹). 원본 ${raw.length}자`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rawGroups = (parsed as { groups?: unknown }).groups;
|
||||
const groups: unknown[] = Array.isArray(rawGroups) ? rawGroups : [];
|
||||
const result: ChecklistSuggestionGroup[] = [];
|
||||
for (const g of groups.slice(0, 8)) {
|
||||
const obj = g as { category?: unknown; items?: unknown };
|
||||
@@ -271,6 +284,36 @@ export class AiService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 깨진/잘린 JSON 에서도 카테고리·항목을 정규식으로 복구(중괄호 누락·꼬리 잘림 방어).
|
||||
// 완결된 "category":"...","items":[ ... ] 블록만 추출하므로, 잘린 마지막 그룹은 버리고 나머지는 살린다.
|
||||
private recoverGroups(text: string): { category: string; items: string[] }[] {
|
||||
const out: { category: string; items: string[] }[] = [];
|
||||
const groupRe =
|
||||
/"category"\s*:\s*("(?:[^"\\]|\\.)*")\s*,\s*"items"\s*:\s*\[([\s\S]*?)\]/g;
|
||||
const itemRe = /"(?:[^"\\]|\\.)*"/g;
|
||||
let gm: RegExpExecArray | null;
|
||||
while ((gm = groupRe.exec(text)) !== null) {
|
||||
let category: string;
|
||||
try {
|
||||
category = JSON.parse(gm[1]) as string;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const items: string[] = [];
|
||||
let im: RegExpExecArray | null;
|
||||
while ((im = itemRe.exec(gm[2])) !== null) {
|
||||
try {
|
||||
const s = JSON.parse(im[0]) as unknown;
|
||||
if (typeof s === 'string') items.push(s);
|
||||
} catch {
|
||||
// 깨진 토큰은 건너뛴다
|
||||
}
|
||||
}
|
||||
out.push({ category, items });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private suggestFailed(): BusinessException {
|
||||
return new BusinessException(
|
||||
'BIZ_001',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Project>;
|
||||
|
||||
// 업로더 (사용자 삭제 시에도 첨부는 유지 — 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;
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<typeof wb.xlsx.load>[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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ProjectAttachmentResponse> {
|
||||
if (!file) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'업로드할 수 없는 파일 형식이거나 파일이 없습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return this.projectService.addFile(projectId, user.id, file);
|
||||
): Promise<ProjectDriveLinkResponse[]> {
|
||||
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<ProjectDriveLinkResponse> {
|
||||
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<null> {
|
||||
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<void> {
|
||||
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<ProjectDocumentResponse[]> {
|
||||
return this.projectService.listDocuments(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ProjectMember>,
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
@InjectRepository(ProjectAttachment)
|
||||
private readonly attachmentRepo: Repository<ProjectAttachment>,
|
||||
@InjectRepository(ProjectFolderLink)
|
||||
private readonly linkRepo: Repository<ProjectFolderLink>,
|
||||
@InjectRepository(DriveFolder)
|
||||
private readonly folderRepo: Repository<DriveFolder>,
|
||||
@InjectRepository(DriveFile)
|
||||
private readonly fileRepo: Repository<DriveFile>,
|
||||
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<ProjectAttachmentResponse[]> {
|
||||
// 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<ProjectDriveLinkResponse[]> {
|
||||
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<ProjectDriveLinkResponse> {
|
||||
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<void> {
|
||||
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<ProjectDocumentResponse[]> {
|
||||
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<string> {
|
||||
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<ProjectAttachmentResponse> {
|
||||
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<DriveFile[]> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<string>();
|
||||
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<number> {
|
||||
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<void> {
|
||||
// 드라이브 파일 1건 텍스트 추출(NCP 다운로드 → 추출 → 캐싱). 결과 상태를 함께 기록.
|
||||
private async extractDriveFile(f: DriveFile): Promise<string | null> {
|
||||
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';
|
||||
}
|
||||
|
||||
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 문서의 AI 추천 반영 상태 배지
|
||||
// ready: 반영 가능 / failed: 추출 실패 / unsupported: 미지원 형식
|
||||
// ready: 반영됨 / pending: 추출 대기 / failed: 추출 실패 / unsupported: 미지원 형식
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
status: 'ready' | 'failed' | 'unsupported'
|
||||
status: 'ready' | 'pending' | 'failed' | 'unsupported'
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
@@ -15,6 +15,11 @@ const META: Record<Props['status'], { label: string; title: string; cls: string
|
||||
title: 'AI 업무 추천에 이 문서 내용이 반영됩니다.',
|
||||
cls: 'ok',
|
||||
},
|
||||
pending: {
|
||||
label: '추출 대기',
|
||||
title: '다음 AI 업무 추천 시 문서 내용을 추출해 반영합니다.',
|
||||
cls: 'muted',
|
||||
},
|
||||
failed: {
|
||||
label: '추출 실패',
|
||||
title: '문서에서 텍스트를 추출하지 못해 AI 추천에 반영되지 않습니다.',
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
// 드라이브 폴더 선택 모달 — 내 드라이브를 탐색해 프로젝트에 연동할 폴더를 고른다.
|
||||
import { ref } from 'vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { useDrive } from '@/composables/useDrive'
|
||||
import type { DriveCrumb, DriveFolder } from '@/types/drive'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', folder: { id: string; name: string }): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const drive = useDrive()
|
||||
const loading = ref(false)
|
||||
const breadcrumb = ref<DriveCrumb[]>([])
|
||||
const folders = ref<DriveFolder[]>([])
|
||||
|
||||
async function load(id: string | null): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await drive.list(id ?? undefined)
|
||||
breadcrumb.value = res.breadcrumb
|
||||
folders.value = res.folders
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function pick(f: DriveFolder): void {
|
||||
emit('select', { id: f.id, name: f.name })
|
||||
}
|
||||
|
||||
void load(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
title="연동할 폴더 선택"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #subtitle>
|
||||
<b>내 드라이브</b>의 폴더(하위 포함)를 프로젝트의 AI 검토 문서로 연동합니다.
|
||||
</template>
|
||||
|
||||
<nav class="dfp-crumb">
|
||||
<a
|
||||
v-if="breadcrumb.length"
|
||||
href="#"
|
||||
@click.prevent="load(null)"
|
||||
>드라이브</a>
|
||||
<b v-else>드라이브</b>
|
||||
<template
|
||||
v-for="(c, i) in breadcrumb"
|
||||
:key="c.id"
|
||||
>
|
||||
<span class="dfp-sep">›</span>
|
||||
<a
|
||||
v-if="i < breadcrumb.length - 1"
|
||||
href="#"
|
||||
@click.prevent="load(c.id)"
|
||||
>{{ c.name }}</a>
|
||||
<b v-else>{{ c.name }}</b>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<p
|
||||
v-if="loading"
|
||||
class="dfp-msg"
|
||||
>
|
||||
불러오는 중…
|
||||
</p>
|
||||
<EmptyState v-else-if="folders.length === 0">
|
||||
이 위치에 하위 폴더가 없습니다. 드라이브에서 폴더를 먼저 만들어 주세요.
|
||||
</EmptyState>
|
||||
<div
|
||||
v-else
|
||||
class="dfp-list"
|
||||
>
|
||||
<div
|
||||
v-for="f in folders"
|
||||
:key="f.id"
|
||||
class="dfp-row"
|
||||
>
|
||||
<button
|
||||
class="dfp-open"
|
||||
type="button"
|
||||
title="폴더 열기"
|
||||
@click="load(f.id)"
|
||||
>
|
||||
<span class="dfp-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
|
||||
</span>
|
||||
<span class="dfp-name">{{ f.name }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn sm"
|
||||
type="button"
|
||||
@click="pick(f)"
|
||||
>
|
||||
연동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #foot>
|
||||
<button
|
||||
class="mbtn"
|
||||
@click="emit('close')"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dfp-crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.dfp-crumb a {
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dfp-crumb a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.dfp-sep {
|
||||
color: var(--text-3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.dfp-msg {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
.dfp-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
overflow: hidden;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dfp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.dfp-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.dfp-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.dfp-open {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
.dfp-ico {
|
||||
flex: 0 0 auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.dfp-ico svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.dfp-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
// 드라이브 이름 입력 모달 — 폴더 생성 / 폴더·파일 이름 변경 공용
|
||||
import { computed, ref } from 'vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
init?: string
|
||||
confirmText?: string
|
||||
}>(),
|
||||
{ label: '이름', placeholder: '', init: '', confirmText: '저장' },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', name: string): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const name = ref(props.init)
|
||||
const valid = computed(() => {
|
||||
const v = name.value.trim()
|
||||
return v.length >= 1 && v.length <= 255
|
||||
})
|
||||
|
||||
function submit(): void {
|
||||
if (!valid.value) return
|
||||
emit('save', name.value.trim())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:title="title"
|
||||
size="sm"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<label class="form-field"><span class="form-label"><span class="req">*</span> {{ label }}</span>
|
||||
<input
|
||||
v-model="name"
|
||||
class="form-input"
|
||||
:placeholder="placeholder"
|
||||
maxlength="255"
|
||||
autofocus
|
||||
@keydown.enter="submit"
|
||||
>
|
||||
</label>
|
||||
|
||||
<template #foot>
|
||||
<button
|
||||
class="mbtn"
|
||||
@click="emit('close')"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="mbtn primary"
|
||||
:disabled="!valid"
|
||||
@click="submit"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -168,15 +168,6 @@ async function copyInvite(room: ApiMeetingRoom) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn rr-invite"
|
||||
:class="{ copied: copiedCode === r.code }"
|
||||
:title="copiedCode === r.code ? '복사됨!' : '초대 링크 복사'"
|
||||
@click="copyInvite(r)"
|
||||
>
|
||||
<MeetingIcon :name="copiedCode === r.code ? 'check' : 'link'" />
|
||||
</button>
|
||||
<button
|
||||
v-if="r.canJoin"
|
||||
type="button"
|
||||
@@ -194,6 +185,15 @@ async function copyInvite(room: ApiMeetingRoom) {
|
||||
>
|
||||
<MeetingIcon name="lock" />권한 없음
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn rr-invite"
|
||||
:class="{ copied: copiedCode === r.code }"
|
||||
:title="copiedCode === r.code ? '복사됨!' : '초대 링크 복사'"
|
||||
@click="copyInvite(r)"
|
||||
>
|
||||
<MeetingIcon :name="copiedCode === r.code ? 'check' : 'link'" />
|
||||
</button>
|
||||
<button
|
||||
v-if="r.isHost"
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
DriveFile,
|
||||
DriveFolder,
|
||||
DriveList,
|
||||
PresignUploadPayload,
|
||||
PresignUploadResult,
|
||||
} from '@/types/drive'
|
||||
|
||||
// 드라이브 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
// (바이너리 송수신은 백엔드를 거치지 않고 presigned URL 로 직접 — store 에서 처리)
|
||||
export function useDrive() {
|
||||
const api = useApi()
|
||||
|
||||
// 목록 (folderId 생략 시 루트)
|
||||
async function list(folderId?: string): Promise<DriveList> {
|
||||
return (await api.get('/drive', {
|
||||
params: folderId ? { folderId } : {},
|
||||
})) as unknown as DriveList
|
||||
}
|
||||
|
||||
// 폴더
|
||||
async function createFolder(
|
||||
name: string,
|
||||
parentId?: string,
|
||||
): Promise<DriveFolder> {
|
||||
return (await api.post('/drive/folders', {
|
||||
name,
|
||||
...(parentId ? { parentId } : {}),
|
||||
})) as unknown as DriveFolder
|
||||
}
|
||||
async function renameFolder(id: string, name: string): Promise<DriveFolder> {
|
||||
return (await api.patch(`/drive/folders/${id}`, { name })) as unknown as DriveFolder
|
||||
}
|
||||
async function deleteFolder(id: string): Promise<void> {
|
||||
await api.delete(`/drive/folders/${id}`)
|
||||
}
|
||||
|
||||
// 파일 — presigned 3단계 업로드(① presign → ② 직접 PUT[store] → ③ complete)
|
||||
async function presignUpload(
|
||||
payload: PresignUploadPayload,
|
||||
): Promise<PresignUploadResult> {
|
||||
return (await api.post('/drive/files/presign', payload)) as unknown as PresignUploadResult
|
||||
}
|
||||
async function completeUpload(fileId: string): Promise<DriveFile> {
|
||||
return (await api.post(`/drive/files/${fileId}/complete`)) as unknown as DriveFile
|
||||
}
|
||||
async function getDownloadUrl(fileId: string): Promise<string> {
|
||||
const res = (await api.get(`/drive/files/${fileId}/download-url`)) as unknown as {
|
||||
url: string
|
||||
}
|
||||
return res.url
|
||||
}
|
||||
async function renameFile(id: string, name: string): Promise<DriveFile> {
|
||||
return (await api.patch(`/drive/files/${id}`, { name })) as unknown as DriveFile
|
||||
}
|
||||
async function deleteFile(id: string): Promise<void> {
|
||||
await api.delete(`/drive/files/${id}`)
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
createFolder,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
presignUpload,
|
||||
completeUpload,
|
||||
getDownloadUrl,
|
||||
renameFile,
|
||||
deleteFile,
|
||||
}
|
||||
}
|
||||
@@ -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<ApiProjectAttachment> {
|
||||
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<ApiProjectDriveLink[]> {
|
||||
return (await api.get(
|
||||
`/projects/${projectId}/drive-links`,
|
||||
)) as unknown as ApiProjectDriveLink[]
|
||||
}
|
||||
|
||||
// 문서 첨부 삭제(관리자)
|
||||
async function removeFile(projectId: string, fileId: string): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}/files/${fileId}`)
|
||||
// 드라이브 폴더 연동(관리자, 본인 소유 폴더만)
|
||||
async function linkDriveFolder(
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
permission?: 'read' | 'read-write',
|
||||
): Promise<ApiProjectDriveLink> {
|
||||
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<void> {
|
||||
await api.delete(`/projects/${projectId}/drive-links/${linkId}`)
|
||||
}
|
||||
|
||||
// AI 검토 대상 문서 목록(연동 폴더 내 파일)
|
||||
async function listDocuments(
|
||||
projectId: string,
|
||||
): Promise<ApiProjectDocument[]> {
|
||||
return (await api.get(
|
||||
`/projects/${projectId}/documents`,
|
||||
)) as unknown as ApiProjectDocument[]
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
listDriveLinks,
|
||||
linkDriveFolder,
|
||||
unlinkDriveFolder,
|
||||
listDocuments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ function toggleCollapse() {
|
||||
const mobileOpen = ref(false)
|
||||
|
||||
// 현재 경로 기준으로 사이드바 네비 활성 항목을 판별
|
||||
const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting'>(() => {
|
||||
const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting' | 'drive'>(() => {
|
||||
if (route.path.startsWith('/projects')) return 'projects'
|
||||
if (route.path.startsWith('/schedule')) return 'schedule'
|
||||
if (route.path.startsWith('/meeting')) return 'meeting'
|
||||
if (route.path.startsWith('/drive')) return 'drive'
|
||||
return 'tasks'
|
||||
})
|
||||
|
||||
@@ -227,6 +228,24 @@ async function onLogout() {
|
||||
</svg>
|
||||
<span>화상회의</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/drive"
|
||||
class="nav-item"
|
||||
title="드라이브"
|
||||
:class="{ active: activeNav === 'drive' }"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||
</svg>
|
||||
<span>드라이브</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
/** 프로젝트의 업무 목록 행 */
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
<script setup lang="ts">
|
||||
// 드라이브(파일 저장소) — 폴더 탐색 + presigned 업/다운로드 + 이름변경/삭제
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import DriveNameModal from '@/components/drive/DriveNameModal.vue'
|
||||
import { useDriveStore } from '@/stores/drive.store'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { fileBadge, formatBytes, relativeTimeKo } from '@/shared/utils/format'
|
||||
import {
|
||||
DRIVE_ACCEPT,
|
||||
DRIVE_MAX_FILE_SIZE,
|
||||
isAllowedDriveMime,
|
||||
resolveMimeType,
|
||||
} from '@/shared/utils/drive'
|
||||
import type { DriveFile, DriveFolder } from '@/types/drive'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useDriveStore()
|
||||
const dialog = useDialog()
|
||||
const { current, breadcrumb, folders, files, loading, uploads, itemCount, isEmpty } =
|
||||
storeToRefs(store)
|
||||
|
||||
// 로컬 상태 — 검색(현재 폴더 내), 모달, 드래그, 파일 입력
|
||||
const keyword = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
type ModalState =
|
||||
| { kind: 'create-folder' }
|
||||
| { kind: 'rename-folder'; id: string; name: string }
|
||||
| { kind: 'rename-file'; id: string; name: string }
|
||||
| null
|
||||
const modal = ref<ModalState>(null)
|
||||
|
||||
// 드래그앤드롭(중첩 요소 깜빡임 방지용 depth 카운터)
|
||||
const dragDepth = ref(0)
|
||||
const isDragging = computed(() => dragDepth.value > 0)
|
||||
|
||||
// --- 검색 필터(클라이언트, 현재 폴더 한정) ---
|
||||
const kw = computed(() => keyword.value.trim().toLowerCase())
|
||||
const shownFolders = computed(() =>
|
||||
kw.value ? folders.value.filter((f) => f.name.toLowerCase().includes(kw.value)) : folders.value,
|
||||
)
|
||||
const shownFiles = computed(() =>
|
||||
kw.value ? files.value.filter((f) => f.name.toLowerCase().includes(kw.value)) : files.value,
|
||||
)
|
||||
const noResult = computed(
|
||||
() => shownFolders.value.length === 0 && shownFiles.value.length === 0,
|
||||
)
|
||||
|
||||
// 모달 표시 속성
|
||||
const modalProps = computed(() => {
|
||||
const m = modal.value
|
||||
if (!m) return null
|
||||
if (m.kind === 'create-folder') {
|
||||
return { title: '새 폴더', label: '폴더 이름', placeholder: '폴더 이름', init: '', confirmText: '만들기' }
|
||||
}
|
||||
if (m.kind === 'rename-folder') {
|
||||
return { title: '폴더 이름 변경', label: '폴더 이름', placeholder: '', init: m.name, confirmText: '저장' }
|
||||
}
|
||||
return { title: '파일 이름 변경', label: '파일 이름', placeholder: '', init: m.name, confirmText: '저장' }
|
||||
})
|
||||
|
||||
// --- 탐색 ---
|
||||
function openFolder(f: DriveFolder): void {
|
||||
router.push(`/drive/${f.id}`)
|
||||
}
|
||||
function goRoot(): void {
|
||||
router.push('/drive')
|
||||
}
|
||||
|
||||
// --- 모달 열기 ---
|
||||
function openCreateFolder(): void {
|
||||
modal.value = { kind: 'create-folder' }
|
||||
}
|
||||
function openRenameFolder(f: DriveFolder): void {
|
||||
modal.value = { kind: 'rename-folder', id: f.id, name: f.name }
|
||||
}
|
||||
function openRenameFile(f: DriveFile): void {
|
||||
modal.value = { kind: 'rename-file', id: f.id, name: f.name }
|
||||
}
|
||||
|
||||
async function onModalSave(name: string): Promise<void> {
|
||||
const m = modal.value
|
||||
if (!m) return
|
||||
try {
|
||||
if (m.kind === 'create-folder') await store.createFolder(name)
|
||||
else if (m.kind === 'rename-folder') await store.renameFolder(m.id, name)
|
||||
else await store.renameFile(m.id, name)
|
||||
modal.value = null
|
||||
} catch {
|
||||
// 에러는 API 인터셉터가 다이얼로그로 표시 — 모달은 열어 둔다
|
||||
}
|
||||
}
|
||||
|
||||
// --- 삭제 ---
|
||||
async function delFolder(f: DriveFolder): Promise<void> {
|
||||
const ok = await dialog.confirm(
|
||||
`'${f.name}' 폴더를 삭제할까요? 하위 폴더·파일이 모두 삭제되며 되돌릴 수 없습니다.`,
|
||||
{ title: '폴더 삭제', confirmText: '삭제', variant: 'danger' },
|
||||
)
|
||||
if (!ok) return
|
||||
await store.removeFolder(f.id)
|
||||
}
|
||||
async function delFile(f: DriveFile): Promise<void> {
|
||||
const ok = await dialog.confirm(
|
||||
`'${f.name}' 파일을 삭제할까요? 이 작업은 되돌릴 수 없습니다.`,
|
||||
{ title: '파일 삭제', confirmText: '삭제', variant: 'danger' },
|
||||
)
|
||||
if (!ok) return
|
||||
await store.removeFile(f.id)
|
||||
}
|
||||
|
||||
// --- 업로드 ---
|
||||
function pickFiles(): void {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
function onFileInput(e: Event): void {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) handleFiles(Array.from(input.files))
|
||||
input.value = '' // 같은 파일 재선택 허용
|
||||
}
|
||||
|
||||
// 검증 후 허용 파일만 업로드 시작(거부 항목은 모아서 안내)
|
||||
function handleFiles(list: File[]): void {
|
||||
const rejected: string[] = []
|
||||
const accepted: { file: File; mime: string }[] = []
|
||||
for (const f of list) {
|
||||
const mime = resolveMimeType(f)
|
||||
if (f.size === 0) rejected.push(`${f.name} (빈 파일)`)
|
||||
else if (f.size > DRIVE_MAX_FILE_SIZE) rejected.push(`${f.name} (용량 초과 · 최대 ${formatBytes(DRIVE_MAX_FILE_SIZE)})`)
|
||||
else if (!isAllowedDriveMime(mime)) rejected.push(`${f.name} (지원하지 않는 형식)`)
|
||||
else accepted.push({ file: f, mime })
|
||||
}
|
||||
if (rejected.length) {
|
||||
dialog.alert(`다음 파일은 업로드할 수 없습니다.\n\n${rejected.join('\n')}`, {
|
||||
title: '업로드 불가',
|
||||
variant: 'danger',
|
||||
})
|
||||
}
|
||||
for (const a of accepted) void store.uploadOne(a.file, a.mime)
|
||||
}
|
||||
|
||||
// 드래그앤드롭
|
||||
function onDragEnter(): void {
|
||||
dragDepth.value++
|
||||
}
|
||||
function onDragLeave(): void {
|
||||
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
||||
}
|
||||
function onDrop(e: DragEvent): void {
|
||||
dragDepth.value = 0
|
||||
const dropped = e.dataTransfer?.files
|
||||
if (dropped && dropped.length) handleFiles(Array.from(dropped))
|
||||
}
|
||||
|
||||
// 라우트(folderId) 변경 시 목록 로드 — 진입/뒤로가기 포함
|
||||
watch(
|
||||
() => route.params.folderId,
|
||||
(v) => {
|
||||
const id = typeof v === 'string' && v ? v : null
|
||||
keyword.value = ''
|
||||
void store.fetchList(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div
|
||||
class="page"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<!-- 브레드크럼 -->
|
||||
<nav class="crumb dr-crumb">
|
||||
<template v-if="breadcrumb.length === 0">
|
||||
<b>드라이브</b>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="goRoot"
|
||||
>드라이브</a>
|
||||
<template
|
||||
v-for="(c, i) in breadcrumb"
|
||||
:key="c.id"
|
||||
>
|
||||
<span class="dr-sep">›</span>
|
||||
<RouterLink
|
||||
v-if="i < breadcrumb.length - 1"
|
||||
:to="`/drive/${c.id}`"
|
||||
>
|
||||
{{ c.name }}
|
||||
</RouterLink>
|
||||
<b v-else>{{ c.name }}</b>
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>{{ current ? current.name : '드라이브' }}</h1>
|
||||
<span class="count-pill">{{ itemCount }}</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
파일과 폴더를 저장하고 관리하세요. 파일을 이 영역에 끌어다 놓아 업로드할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="11"
|
||||
cy="11"
|
||||
r="7"
|
||||
/>
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="search"
|
||||
placeholder="이 폴더에서 검색"
|
||||
aria-label="현재 폴더 내 검색"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
@click="openCreateFolder"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||
<path d="M12 11v4M10 13h4" />
|
||||
</svg>
|
||||
새 폴더
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="button"
|
||||
@click="pickFiles"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 16V4M7 9l5-5 5 5" />
|
||||
<path d="M5 20h14" />
|
||||
</svg>
|
||||
업로드
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="DRIVE_ACCEPT"
|
||||
class="dr-file-input"
|
||||
@change="onFileInput"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 업로드 진행 패널 -->
|
||||
<div
|
||||
v-if="uploads.length"
|
||||
class="dr-uploads"
|
||||
>
|
||||
<div
|
||||
v-for="u in uploads"
|
||||
:key="u.id"
|
||||
class="dr-up"
|
||||
:class="{ error: u.status === 'error' }"
|
||||
>
|
||||
<span class="dr-up-name">{{ u.name }}</span>
|
||||
<span class="dr-up-bar">
|
||||
<span
|
||||
class="dr-up-fill"
|
||||
:class="{ error: u.status === 'error' }"
|
||||
:style="{ width: (u.status === 'error' ? 100 : u.progress) + '%' }"
|
||||
/>
|
||||
</span>
|
||||
<span class="dr-up-status">
|
||||
<template v-if="u.status === 'uploading'">{{ u.progress }}%</template>
|
||||
<template v-else-if="u.status === 'completing'">확정 중…</template>
|
||||
<template v-else>{{ u.error || '실패' }}</template>
|
||||
</span>
|
||||
<button
|
||||
v-if="u.status === 'error'"
|
||||
class="dr-up-x"
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
@click="store.dismissUpload(u.id)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div class="dr-listwrap">
|
||||
<EmptyState v-if="loading && isEmpty">
|
||||
불러오는 중…
|
||||
</EmptyState>
|
||||
<EmptyState v-else-if="isEmpty">
|
||||
이 폴더가 비어 있습니다. 파일을 끌어다 놓거나 ‘업로드’로 파일을 추가해 보세요.
|
||||
</EmptyState>
|
||||
<EmptyState v-else-if="noResult">
|
||||
검색 결과가 없습니다.
|
||||
</EmptyState>
|
||||
<div
|
||||
v-else
|
||||
class="list"
|
||||
>
|
||||
<!-- 폴더 -->
|
||||
<div
|
||||
v-for="f in shownFolders"
|
||||
:key="'fd-' + f.id"
|
||||
class="dr-row"
|
||||
>
|
||||
<button
|
||||
class="dr-hit"
|
||||
type="button"
|
||||
@click="openFolder(f)"
|
||||
>
|
||||
<span class="dr-ico dr-folder">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
|
||||
</span>
|
||||
<span class="dr-main">
|
||||
<span class="dr-name">{{ f.name }}</span>
|
||||
<span class="dr-meta">폴더 · {{ relativeTimeKo(f.updatedAt) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="dr-actions">
|
||||
<button
|
||||
class="btn dr-act"
|
||||
type="button"
|
||||
title="이름 변경"
|
||||
@click="openRenameFolder(f)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn danger dr-act"
|
||||
type="button"
|
||||
title="삭제"
|
||||
@click="delFolder(f)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 파일 -->
|
||||
<div
|
||||
v-for="f in shownFiles"
|
||||
:key="'fl-' + f.id"
|
||||
class="dr-row"
|
||||
>
|
||||
<button
|
||||
class="dr-hit"
|
||||
type="button"
|
||||
title="다운로드"
|
||||
@click="store.download(f.id)"
|
||||
>
|
||||
<span
|
||||
class="dr-ico dr-badge"
|
||||
:class="fileBadge(f.kind).cls"
|
||||
>{{ fileBadge(f.kind).label }}</span>
|
||||
<span class="dr-main">
|
||||
<span class="dr-name">{{ f.name }}</span>
|
||||
<span class="dr-meta">{{ formatBytes(f.size) }} · {{ relativeTimeKo(f.updatedAt) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="dr-actions">
|
||||
<button
|
||||
class="btn dr-act"
|
||||
type="button"
|
||||
title="다운로드"
|
||||
@click="store.download(f.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 4v12M7 11l5 5 5-5" /><path d="M5 20h14" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn dr-act"
|
||||
type="button"
|
||||
title="이름 변경"
|
||||
@click="openRenameFile(f)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn danger dr-act"
|
||||
type="button"
|
||||
title="삭제"
|
||||
@click="delFile(f)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 드래그 오버레이 -->
|
||||
<div
|
||||
v-if="isDragging"
|
||||
class="dr-drop"
|
||||
>
|
||||
<div class="dr-drop-card">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 16V4M7 9l5-5 5 5" />
|
||||
<path d="M5 20h14" />
|
||||
</svg>
|
||||
<span>여기에 놓아 업로드</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 폴더 생성 / 이름 변경 모달 -->
|
||||
<DriveNameModal
|
||||
v-if="modal && modalProps"
|
||||
:title="modalProps.title"
|
||||
:label="modalProps.label"
|
||||
:placeholder="modalProps.placeholder"
|
||||
:init="modalProps.init"
|
||||
:confirm-text="modalProps.confirmText"
|
||||
@save="onModalSave"
|
||||
@close="modal = null"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 브레드크럼 링크 */
|
||||
.dr-crumb a,
|
||||
.dr-crumb :deep(a) {
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dr-crumb a:hover,
|
||||
.dr-crumb :deep(a:hover) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.dr-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 — 다른 목록 페이지(ProjectListPage)와 동일 스페이싱 */
|
||||
.pagehead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6875rem;
|
||||
padding: 0.5625rem 0 0.25rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lede {
|
||||
color: var(--text-2);
|
||||
font-size: 0.844rem;
|
||||
margin: 0.25rem 0 1.125rem;
|
||||
}
|
||||
|
||||
/* 툴바 — 검색은 늘리고 버튼은 우측 */
|
||||
.toolbar .search {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.dr-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 업로드 진행 패널 */
|
||||
.dr-uploads {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.dr-up {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.dr-up.error {
|
||||
border-color: var(--red-border);
|
||||
background: var(--red-weak);
|
||||
}
|
||||
.dr-up-name {
|
||||
flex: 0 0 14rem;
|
||||
max-width: 14rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dr-up-bar {
|
||||
flex: 1 1 auto;
|
||||
height: 0.5rem;
|
||||
background: var(--bg);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dr-up-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
.dr-up-fill.error {
|
||||
background: var(--red);
|
||||
}
|
||||
.dr-up-status {
|
||||
flex: 0 0 auto;
|
||||
min-width: 3rem;
|
||||
text-align: right;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dr-up.error .dr-up-status {
|
||||
color: var(--red);
|
||||
}
|
||||
.dr-up-x {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.dr-up-x:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 목록 — 전역 .list 카드(radius 0.625rem + shadow-sm) 사용 */
|
||||
.dr-listwrap {
|
||||
position: relative;
|
||||
}
|
||||
.dr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.dr-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.dr-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.dr-hit {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.dr-ico {
|
||||
flex: 0 0 auto;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5625rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.dr-ico svg {
|
||||
width: 1.3125rem;
|
||||
height: 1.3125rem;
|
||||
}
|
||||
.dr-folder {
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
/* 파일 종류 배지(.fi-* 색상 — TaskDetail 첨부와 동일 팔레트) */
|
||||
.dr-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
.dr-badge.fi-img {
|
||||
background: #2aa775;
|
||||
}
|
||||
.dr-badge.fi-pdf {
|
||||
background: #e25950;
|
||||
}
|
||||
.dr-badge.fi-zip {
|
||||
background: #8a64d6;
|
||||
}
|
||||
.dr-badge.fi-doc {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.dr-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.dr-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dr-row:hover .dr-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.dr-meta {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 행 액션 — 전역 .btn 아이콘 버튼(회의 로비와 동일 패턴) */
|
||||
.dr-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.dr-act {
|
||||
width: 2.25rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.dr-act:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
/* danger 변형(전역 .btn 보강 — 회의 로비와 동일) */
|
||||
.btn.danger {
|
||||
background: #fff;
|
||||
border-color: var(--red-border);
|
||||
color: var(--red);
|
||||
}
|
||||
.btn.danger:hover {
|
||||
background: var(--red-weak);
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.dr-up-name {
|
||||
flex-basis: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 드래그 오버레이 */
|
||||
.dr-drop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 0.625rem;
|
||||
background: color-mix(in srgb, var(--accent-weak) 85%, transparent);
|
||||
border: 2px dashed var(--accent-border);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
.dr-drop-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.dr-drop-card svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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<File[]>([])
|
||||
const fileInput = ref<HTMLInputElement | null>(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<number> {
|
||||
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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 문서 첨부 -->
|
||||
<!-- 문서 안내 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 문서
|
||||
AI 검토 문서
|
||||
</div>
|
||||
<div class="fhint">
|
||||
기획서·가이드 등 관련 문서를 첨부하세요. (1개당 최대 25MB)
|
||||
</div>
|
||||
<div class="files">
|
||||
<div
|
||||
v-for="(f, i) in pendingFiles"
|
||||
:key="i"
|
||||
class="file-row"
|
||||
>
|
||||
<span class="fr-name">{{ f.name }}</span>
|
||||
<span class="fr-size">{{ formatBytes(f.size) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="fr-x"
|
||||
title="제거"
|
||||
@click="removePending(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="file-add"
|
||||
@click="triggerFilePicker"
|
||||
>
|
||||
+ 파일 추가
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
@change="onPickFiles"
|
||||
>
|
||||
프로젝트 생성 후 <b>설정 → AI 검토 문서</b>에서 내 드라이브 폴더를 연동하면,
|
||||
그 폴더의 문서가 AI 업무 추천에 활용됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<TaskSegment>('all')
|
||||
// 세그먼트 탭 옵션(수량 포함) — counts 갱신에 반응
|
||||
@@ -174,9 +187,9 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
:can-manage="repo.canManage"
|
||||
/>
|
||||
|
||||
<!-- 프로젝트 문서(있을 때만, 다운로드 전용) -->
|
||||
<!-- AI 검토 문서(연동 드라이브 폴더 내 파일, 다운로드 전용) -->
|
||||
<div
|
||||
v-if="repo.attachments && repo.attachments.length"
|
||||
v-if="(repo.documents && repo.documents.length) || (repo.driveLinks && repo.driveLinks.length)"
|
||||
class="proj-docs"
|
||||
>
|
||||
<div class="pd-head">
|
||||
@@ -188,20 +201,37 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
|
||||
프로젝트 문서 <span class="pd-ct">{{ repo.attachments.length }}</span>
|
||||
AI 검토 문서 <span class="pd-ct">{{ repo.documents?.length ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="pd-list">
|
||||
<a
|
||||
v-for="a in repo.attachments"
|
||||
:key="a.id"
|
||||
<p
|
||||
v-if="repo.driveLinks && repo.driveLinks.length"
|
||||
class="pd-sub"
|
||||
>
|
||||
연동된 드라이브 폴더 {{ repo.driveLinks.length }}개의 문서를 AI 업무 추천에 활용합니다.
|
||||
</p>
|
||||
<div
|
||||
v-if="repo.documents && repo.documents.length"
|
||||
class="pd-list"
|
||||
>
|
||||
<button
|
||||
v-for="d in repo.documents"
|
||||
:key="d.id"
|
||||
class="pd-item"
|
||||
:href="toAbsoluteApiUrl(a.url)"
|
||||
download
|
||||
type="button"
|
||||
title="다운로드"
|
||||
@click="downloadDoc(d.id)"
|
||||
>
|
||||
<span class="pd-name">{{ a.name }}</span>
|
||||
<span class="pd-size">{{ formatBytes(a.size) }}</span>
|
||||
</a>
|
||||
<span class="pd-name">{{ d.name }}</span>
|
||||
<AttachmentAiBadge :status="d.aiStatus" />
|
||||
<span class="pd-size">{{ formatBytes(d.size) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="pd-empty"
|
||||
>
|
||||
연동 폴더에 문서가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 툴바 -->
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Project | null>(null)
|
||||
// 첨부는 폼 미저장 입력을 보존하기 위해 별도 ref 로 관리(업/삭제 시 부분 갱신)
|
||||
const attachments = ref<ApiProjectAttachment[]>([])
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileBusy = ref(false)
|
||||
// 연동 폴더는 폼 미저장 입력 보존을 위해 별도 ref 로 관리(연동/해제 시 부분 갱신)
|
||||
const driveLinks = ref<ApiProjectDriveLink[]>([])
|
||||
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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 -->
|
||||
<!-- AI 검토 문서 (드라이브 폴더 연동) -->
|
||||
<h2 class="sec-title">
|
||||
문서
|
||||
AI 검토 문서
|
||||
</h2>
|
||||
<div class="card">
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 문서
|
||||
드라이브 폴더 연동
|
||||
</div>
|
||||
<div class="fhint">
|
||||
기획서·가이드 등 관련 문서를 첨부하세요. (1개당 최대 25MB)
|
||||
내 드라이브의 폴더를 연동하면, 그 폴더(하위 포함)의 문서가 AI 업무 추천에 활용됩니다.
|
||||
연동된 폴더의 문서는 프로젝트 멤버가 열람·다운로드할 수 있습니다.
|
||||
</div>
|
||||
<div class="files">
|
||||
<a
|
||||
v-for="a in attachments"
|
||||
:key="a.id"
|
||||
<div
|
||||
v-for="l in driveLinks"
|
||||
:key="l.id"
|
||||
class="file-row"
|
||||
:href="toAbsoluteApiUrl(a.url)"
|
||||
download
|
||||
>
|
||||
<span class="fr-name">{{ a.name }}</span>
|
||||
<AttachmentAiBadge :status="a.aiStatus" />
|
||||
<span class="fr-size">{{ formatBytes(a.size) }}</span>
|
||||
<span class="fr-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
|
||||
</span>
|
||||
<span class="fr-name">{{ l.folder.name }}</span>
|
||||
<span class="fr-size">문서 {{ l.documentCount }}개</span>
|
||||
<button
|
||||
type="button"
|
||||
class="fr-x"
|
||||
title="삭제"
|
||||
@click.prevent="removeAttachment(a.id)"
|
||||
>×</button>
|
||||
</a>
|
||||
title="연동 해제"
|
||||
@click="removeLink(l)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="attachments.length === 0"
|
||||
v-if="driveLinks.length === 0"
|
||||
class="files-empty"
|
||||
>
|
||||
첨부된 문서가 없습니다.
|
||||
연동된 폴더가 없습니다.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="file-add"
|
||||
:disabled="fileBusy"
|
||||
@click="triggerFilePicker"
|
||||
:disabled="linkBusy"
|
||||
@click="pickerOpen = true"
|
||||
>
|
||||
{{ fileBusy ? '업로드 중…' : '+ 파일 추가' }}
|
||||
{{ linkBusy ? '연동 중…' : '+ 폴더 연동' }}
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
@change="onPickFiles"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +319,13 @@ async function removeProject() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 드라이브 폴더 선택 모달 -->
|
||||
<DriveFolderPicker
|
||||
v-if="pickerOpen"
|
||||
@select="onPickFolder"
|
||||
@close="pickerOpen = false"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -115,6 +115,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/relay/MeetingPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 드라이브 — 개인 파일 저장소. :folderId 로 폴더 직접 진입(/drive/<폴더ID>)
|
||||
path: '/drive/:folderId?',
|
||||
name: 'drive',
|
||||
component: () => import('@/pages/relay/DrivePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// 드라이브 클라이언트 측 상수/순수 유틸 — 업로드 사전 검증·MIME 추론
|
||||
// (백엔드 drive.config.ts 의 화이트리스트/최대크기와 동기화 유지)
|
||||
|
||||
// 파일 1건 최대 크기(기본 200MB) — 백엔드 DRIVE_MAX_FILE_MB 기본값과 일치
|
||||
export const DRIVE_MAX_FILE_SIZE = 200 * 1024 * 1024
|
||||
|
||||
// 허용 MIME 화이트리스트(백엔드 DRIVE_ALLOWED_MIME_TYPES 미러)
|
||||
export const DRIVE_ALLOWED_MIME: ReadonlySet<string> = new Set<string>([
|
||||
// 이미지
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
// 문서
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'text/markdown',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/json',
|
||||
// 압축
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-rar-compressed',
|
||||
// 미디어
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
])
|
||||
|
||||
// <input type="file" accept="..."> 용 확장자 목록
|
||||
export const DRIVE_ACCEPT =
|
||||
'.png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.csv,.md,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.json,.zip,.7z,.rar,.mp4,.webm,.mov,.mp3,.wav,.ogg'
|
||||
|
||||
// 확장자 → MIME 추론(브라우저가 file.type 을 비워줄 때 보완)
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
md: 'text/markdown',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
json: 'application/json',
|
||||
zip: 'application/zip',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
rar: 'application/x-rar-compressed',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
}
|
||||
|
||||
// 파일의 MIME 결정 — file.type 우선, 없으면 확장자로 추론
|
||||
export function resolveMimeType(file: File): string {
|
||||
if (file.type) return file.type
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
return EXT_TO_MIME[ext] ?? ''
|
||||
}
|
||||
|
||||
// 업로드 허용 여부
|
||||
export function isAllowedDriveMime(mime: string): boolean {
|
||||
return DRIVE_ALLOWED_MIME.has(mime)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDrive } from '@/composables/useDrive'
|
||||
import type { DriveCrumb, DriveFile, DriveFolder } from '@/types/drive'
|
||||
import type { PresignedUpload } from '@/types/drive'
|
||||
|
||||
// 진행 중 업로드 1건(UI 표시용)
|
||||
export interface UploadItem {
|
||||
id: number // 로컬 임시 id
|
||||
name: string
|
||||
size: number
|
||||
progress: number // 0~100
|
||||
status: 'uploading' | 'completing' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 이름 오름차순 정렬(백엔드 정렬과 동일 기준 유지)
|
||||
function byName<T extends { name: string }>(a: T, b: T): number {
|
||||
return a.name.localeCompare(b.name, 'ko')
|
||||
}
|
||||
|
||||
// presigned URL 로 객체 스토리지에 직접 PUT — 진행률을 위해 XHR 사용(fetch 는 업로드 진행률 미지원)
|
||||
function putToStorage(
|
||||
upload: PresignedUpload,
|
||||
file: File,
|
||||
onProgress: (pct: number) => void,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open(upload.method, upload.url)
|
||||
// 서명 대상 헤더(Content-Type 등)를 반드시 그대로 전송
|
||||
for (const [k, v] of Object.entries(upload.headers)) {
|
||||
xhr.setRequestHeader(k, v)
|
||||
}
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve()
|
||||
else reject(new Error(`업로드에 실패했습니다. (${xhr.status})`))
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('네트워크 오류로 업로드에 실패했습니다.'))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
let uploadSeq = 0
|
||||
|
||||
export const useDriveStore = defineStore('drive', () => {
|
||||
const drive = useDrive()
|
||||
|
||||
// --- 상태 ---
|
||||
const folderId = ref<string | null>(null) // 현재 폴더(루트면 null)
|
||||
const current = ref<DriveFolder | null>(null)
|
||||
const breadcrumb = ref<DriveCrumb[]>([])
|
||||
const folders = ref<DriveFolder[]>([])
|
||||
const files = ref<DriveFile[]>([])
|
||||
const loading = ref(false)
|
||||
const uploads = ref<UploadItem[]>([])
|
||||
|
||||
const isEmpty = computed(
|
||||
() => folders.value.length === 0 && files.value.length === 0,
|
||||
)
|
||||
const itemCount = computed(() => folders.value.length + files.value.length)
|
||||
|
||||
// --- 조회 ---
|
||||
async function fetchList(id: string | null): Promise<void> {
|
||||
folderId.value = id
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await drive.list(id ?? undefined)
|
||||
current.value = res.current
|
||||
breadcrumb.value = res.breadcrumb
|
||||
folders.value = [...res.folders].sort(byName)
|
||||
files.value = [...res.files].sort(byName)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 폴더 ---
|
||||
async function createFolder(name: string): Promise<void> {
|
||||
const created = await drive.createFolder(name, folderId.value ?? undefined)
|
||||
folders.value = [...folders.value, created].sort(byName)
|
||||
}
|
||||
async function renameFolder(id: string, name: string): Promise<void> {
|
||||
const updated = await drive.renameFolder(id, name)
|
||||
folders.value = folders.value
|
||||
.map((f) => (f.id === id ? updated : f))
|
||||
.sort(byName)
|
||||
}
|
||||
async function removeFolder(id: string): Promise<void> {
|
||||
await drive.deleteFolder(id)
|
||||
folders.value = folders.value.filter((f) => f.id !== id)
|
||||
}
|
||||
|
||||
// --- 파일 ---
|
||||
async function renameFile(id: string, name: string): Promise<void> {
|
||||
const updated = await drive.renameFile(id, name)
|
||||
files.value = files.value.map((f) => (f.id === id ? updated : f)).sort(byName)
|
||||
}
|
||||
async function removeFile(id: string): Promise<void> {
|
||||
await drive.deleteFile(id)
|
||||
files.value = files.value.filter((f) => f.id !== id)
|
||||
}
|
||||
|
||||
// 다운로드 — 단기 presigned URL 발급 후 브라우저 다운로드 트리거
|
||||
async function download(id: string): Promise<void> {
|
||||
const url = await drive.getDownloadUrl(id)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
// 업로드 1건 — presign → 직접 PUT(진행률) → complete. 검증은 호출 전(page)에서 끝낸 상태로 가정.
|
||||
async function uploadOne(file: File, contentType: string): Promise<void> {
|
||||
const targetFolder = folderId.value // 시작 시점의 폴더(완료 시 위치 비교용)
|
||||
const item: UploadItem = {
|
||||
id: ++uploadSeq,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
}
|
||||
uploads.value = [...uploads.value, item]
|
||||
try {
|
||||
const res = await drive.presignUpload({
|
||||
filename: file.name,
|
||||
contentType,
|
||||
size: file.size,
|
||||
folderId: targetFolder ?? undefined,
|
||||
})
|
||||
await putToStorage(res.upload, file, (p) => {
|
||||
item.progress = p
|
||||
})
|
||||
item.status = 'completing'
|
||||
const done = await drive.completeUpload(res.fileId)
|
||||
// 업로드 중 사용자가 폴더를 옮기지 않았다면 현재 목록에 즉시 반영
|
||||
if ((done.folderId ?? null) === (folderId.value ?? null)) {
|
||||
files.value = [...files.value, done].sort(byName)
|
||||
}
|
||||
uploads.value = uploads.value.filter((u) => u.id !== item.id)
|
||||
} catch (e: unknown) {
|
||||
item.status = 'error'
|
||||
item.error = e instanceof Error ? e.message : '업로드에 실패했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
// 완료/실패 업로드 항목 닫기
|
||||
function dismissUpload(id: number): void {
|
||||
uploads.value = uploads.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
folderId,
|
||||
current,
|
||||
breadcrumb,
|
||||
folders,
|
||||
files,
|
||||
loading,
|
||||
uploads,
|
||||
isEmpty,
|
||||
itemCount,
|
||||
fetchList,
|
||||
createFolder,
|
||||
renameFolder,
|
||||
removeFolder,
|
||||
renameFile,
|
||||
removeFile,
|
||||
download,
|
||||
uploadOne,
|
||||
dismissUpload,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// 드라이브(파일 저장소) 타입 — 백엔드 drive 모듈 응답과 1:1 미러
|
||||
// (SSOT: backend/src/modules/drive/drive.service.ts 의 *Response 인터페이스)
|
||||
import type { FileKind } from '@/shared/utils/format'
|
||||
|
||||
// 업로더/소유자(개인 드라이브에선 보통 본인) — PublicUser 부분 미러
|
||||
export interface DriveOwner {
|
||||
id: string
|
||||
name: string
|
||||
email?: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
// 파일 업로드 상태: pending(presign 후 확정 전) | active(확정 완료)
|
||||
export type DriveFileStatus = 'pending' | 'active'
|
||||
|
||||
// 폴더
|
||||
export interface DriveFolder {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
owner: DriveOwner | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 파일
|
||||
export interface DriveFile {
|
||||
id: string
|
||||
name: string
|
||||
size: number // 바이트
|
||||
mime: string
|
||||
kind: FileKind // 'pdf' | 'zip' | 'doc' | 'img'
|
||||
status: DriveFileStatus
|
||||
folderId: string | null
|
||||
owner: DriveOwner | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 브레드크럼 한 칸(루트→현재 경로)
|
||||
export interface DriveCrumb {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// 목록 응답
|
||||
export interface DriveList {
|
||||
current: DriveFolder | null
|
||||
breadcrumb: DriveCrumb[]
|
||||
folders: DriveFolder[]
|
||||
files: DriveFile[]
|
||||
}
|
||||
|
||||
// presigned 업로드 발급 결과(storage.types.PresignedUpload 미러)
|
||||
export interface PresignedUpload {
|
||||
url: string
|
||||
method: 'PUT'
|
||||
headers: Record<string, string>
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
// presign 발급 응답
|
||||
export interface PresignUploadResult {
|
||||
fileId: string
|
||||
file: DriveFile
|
||||
upload: PresignedUpload
|
||||
}
|
||||
|
||||
// presign 요청 본문
|
||||
export interface PresignUploadPayload {
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
folderId?: string
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
화상회의 "화면 띄우기" 시각 테스트 러너 (Windows 네이티브 lk)
|
||||
|
||||
.DESCRIPTION
|
||||
livekit-cli(lk)로 가상 video publisher 를 대량 접속시켜, 운영(또는 지정) LiveKit
|
||||
서버의 특정 방(code)에 합성 영상을 발행한다. 사용자가 같은 방에 브라우저로 입장하면
|
||||
그 가상 참가자들이 실제 영상 타일로 화면에 표시된다(=50명 화면 띄우기 검증).
|
||||
|
||||
⚠️ 왜 Docker(load-test.sh) 가 아니라 네이티브 lk 인가:
|
||||
Docker Desktop(Windows)은 컨테이너 UDP/ICE NAT 한계로 WebRTC 미디어 연결이
|
||||
타임아웃된다. 브라우저와 동일한 네트워크 경로를 쓰려면 호스트 네이티브 lk 가 필요하다.
|
||||
설치: winget install --id LiveKit.LiveKitCLI
|
||||
|
||||
자격증명/URL 은 루트 .env.{APP_ENV} 에서 읽는다(기본 .env.production).
|
||||
- LIVEKIT_API_KEY / LIVEKIT_API_SECRET : lk 가 방 토큰 서명에 사용(브라우저 백엔드와 동일 키)
|
||||
- LIVEKIT_WS_URL : 브라우저가 접속하는 공개 wss URL (백엔드용 LIVEKIT_URL=내부주소 가 아님!)
|
||||
(.env 의 "==" 오타나 CRLF 는 자동 보정한다)
|
||||
|
||||
.PARAMETER Room
|
||||
대상 방의 입장 code. 앱(relay)에서 방을 먼저 생성한 뒤 /meeting/<code> 의 code 를 넣는다.
|
||||
생략 시 임의 이름으로 들어가지만, 그 경우 브라우저는 해당 code 의 DB 방이 없어 입장 불가하다.
|
||||
|
||||
.EXAMPLE
|
||||
# 운영 서버, 특정 방에 50명 저해상도 10분
|
||||
.\livekit\visual-test.ps1 -Room a1b2c3d4e5 -Publishers 50 -Duration 10m
|
||||
|
||||
.EXAMPLE
|
||||
# 화질을 올리되 업로드 대역폭 절약 위해 simulcast 끄기
|
||||
.\livekit\visual-test.ps1 -Room a1b2c3d4e5 -Publishers 30 -Resolution medium -NoSimulcast
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Room,
|
||||
[int] $Publishers = 50,
|
||||
[int] $Subscribers = 0,
|
||||
[string] $Duration = '10m',
|
||||
[ValidateSet('low', 'medium', 'high')] [string] $Resolution = 'low',
|
||||
[int] $NumPerSecond = 5,
|
||||
[switch] $NoSimulcast,
|
||||
[switch] $WithAudio, # 각 가상 유저가 마이크(오디오)도 발행 (영상 인원만큼)
|
||||
[int] $AudioPublishers = 0, # 오디오 발행 인원 직접 지정(고급). -WithAudio 보다 우선
|
||||
[string] $IdentityPrefix = 'load',
|
||||
[string] $EnvFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# 인원/방 코드를 인자로 안 넘기면 대화형으로 입력받는다(매번 플래그 외울 필요 없음).
|
||||
if (-not $PSBoundParameters.ContainsKey('Room') -or [string]::IsNullOrWhiteSpace($Room)) {
|
||||
$Room = (Read-Host '입장할 방 코드(code) 입력 (앱 /meeting/<code> 의 code)').Trim()
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Room)) { throw '방 코드(code)가 필요합니다.' }
|
||||
|
||||
$promptedCount = $false
|
||||
if (-not $PSBoundParameters.ContainsKey('Publishers')) {
|
||||
$promptedCount = $true
|
||||
$ans = (Read-Host "입장시킬 가상 인원 수 [기본 $Publishers]").Trim()
|
||||
if ($ans) {
|
||||
if ($ans -as [int]) { $Publishers = [int]$ans }
|
||||
else { throw "인원 수는 숫자여야 합니다: $ans" }
|
||||
}
|
||||
}
|
||||
if ($Publishers -lt 1) { throw "인원 수는 1명 이상이어야 합니다: $Publishers" }
|
||||
|
||||
# 마이크(오디오) 처리.
|
||||
# - -AudioPublishers N : 그 값 그대로 사용(고급).
|
||||
# - -WithAudio : 영상 인원만큼 오디오도 발행(각 가상 유저가 캠+마이크).
|
||||
# - 둘 다 없고 대화형이면 켤지 물어본다(스크립트 인자모드에서는 안 물어봐 멈춤 없음).
|
||||
if (-not $PSBoundParameters.ContainsKey('AudioPublishers')) {
|
||||
if ($WithAudio) {
|
||||
$AudioPublishers = $Publishers
|
||||
}
|
||||
elseif ($promptedCount) {
|
||||
$a = (Read-Host '마이크(오디오)도 켤까요? 각 가상 유저가 마이크 발행 [y/N]').Trim()
|
||||
if ($a -match '^(y|yes)$') { $AudioPublishers = $Publishers }
|
||||
}
|
||||
}
|
||||
if ($AudioPublishers -lt 0) { $AudioPublishers = 0 }
|
||||
|
||||
# 루트 경로 / env 파일 결정
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
if (-not $EnvFile) {
|
||||
$appEnv = if ($env:APP_ENV) { $env:APP_ENV } else { 'production' }
|
||||
$EnvFile = Join-Path $root ".env.$appEnv"
|
||||
}
|
||||
if (-not (Test-Path $EnvFile)) { throw "env 파일을 찾을 수 없습니다: $EnvFile" }
|
||||
|
||||
# .env 에서 필요한 값만 읽기(주석/CRLF 무시, 마지막 정의 우선)
|
||||
function Read-EnvValue([string]$key) {
|
||||
$line = Get-Content $EnvFile | Where-Object { $_ -match "^\s*$([regex]::Escape($key))=" } | Select-Object -Last 1
|
||||
if (-not $line) { return $null }
|
||||
$val = ($line -split '=', 2)[1]
|
||||
return ($val -replace "`r", '').Trim()
|
||||
}
|
||||
|
||||
$apiKey = Read-EnvValue 'LIVEKIT_API_KEY'
|
||||
$apiSecret = Read-EnvValue 'LIVEKIT_API_SECRET'
|
||||
# 브라우저 접속용 공개 WS URL. .env 의 "==" 오타(앞 등호) 자동 보정.
|
||||
$wsUrl = Read-EnvValue 'LIVEKIT_WS_URL'
|
||||
if ($wsUrl) { $wsUrl = $wsUrl.TrimStart('=').Trim() }
|
||||
|
||||
if (-not $apiKey -or -not $apiSecret -or -not $wsUrl) {
|
||||
throw "LIVEKIT_API_KEY / LIVEKIT_API_SECRET / LIVEKIT_WS_URL 를 $EnvFile 에서 찾을 수 없습니다."
|
||||
}
|
||||
|
||||
# lk 실행 파일 경로 해석 (PATH 우선, 없으면 winget Links 폴백)
|
||||
$lk = (Get-Command lk -ErrorAction SilentlyContinue).Source
|
||||
if (-not $lk) { $lk = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\lk.exe' }
|
||||
if (-not (Test-Path $lk)) {
|
||||
throw "lk(livekit-cli) 를 찾을 수 없습니다. 설치: winget install --id LiveKit.LiveKitCLI"
|
||||
}
|
||||
|
||||
# 자격증명은 환경변수로만 전달(명령줄 노출 금지)
|
||||
$env:LIVEKIT_URL = $wsUrl
|
||||
$env:LIVEKIT_API_KEY = $apiKey
|
||||
$env:LIVEKIT_API_SECRET = $apiSecret
|
||||
|
||||
Write-Host "▶ LiveKit 시각 테스트 (네이티브 lk)" -ForegroundColor Cyan
|
||||
Write-Host " URL=$wsUrl ROOM=$Room"
|
||||
Write-Host " publishers=$Publishers audio=$AudioPublishers subscribers=$Subscribers duration=$Duration resolution=$Resolution simulcast=$([bool](-not $NoSimulcast))"
|
||||
Write-Host " 브라우저에서 같은 방(code=$Room)에 입장하면 가상 참가자 영상이 타일로 보입니다. 중단: Ctrl+C" -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
|
||||
$lkArgs = @(
|
||||
'load-test',
|
||||
'--room', $Room,
|
||||
'--duration', $Duration,
|
||||
'--video-publishers', $Publishers,
|
||||
'--audio-publishers', $AudioPublishers,
|
||||
'--subscribers', $Subscribers,
|
||||
'--video-resolution', $Resolution,
|
||||
'--num-per-second', $NumPerSecond,
|
||||
'--identity-prefix', $IdentityPrefix
|
||||
)
|
||||
if ($NoSimulcast) { $lkArgs += '--no-simulcast' }
|
||||
|
||||
& $lk @lkArgs
|
||||
Reference in New Issue
Block a user