diff --git a/Services/server/.env.example b/Services/server/.env.example index f2eb5d1..0eef5b5 100644 --- a/Services/server/.env.example +++ b/Services/server/.env.example @@ -1,5 +1,4 @@ PUBLIC_KEY= -REALM_URL= PORT= @@ -15,3 +14,7 @@ ELASTICSEARCH_HOST=localhost ELASTICSEARCH_PORT=9200 ELASTICSEARCH_INDEX= +AMQ_URL=amqp://admin:1234@localhost:9999 +AMQ_QUEUE=queue + +AUTH_BYPASS=false # MUST NOT TURN THIS ON IN PRODUCTION diff --git a/Services/server/package.json b/Services/server/package.json index 4e29ce5..b567676 100644 --- a/Services/server/package.json +++ b/Services/server/package.json @@ -9,8 +9,8 @@ "build": "tsoa spec-and-route && tsc", "preview": "node ./dist/app.js", "serve": "node ./dist/app.js", - "docs":"npm run docs:typedoc && npm run docs:swagger", - "docs:typedoc":"typedoc && scp -r be-typedoc projects-doc:~/projects/project-docs/edm/ && rm -r be-typedoc" , + "docs": "npm run docs:typedoc && npm run docs:swagger", + "docs:typedoc": "typedoc && scp -r be-typedoc projects-doc:~/projects/project-docs/edm/ && rm -r be-typedoc", "docs:swagger": "scp src/swagger.json projects-doc:~/projects/project-docs/edm/" }, "keywords": [], @@ -22,6 +22,7 @@ "@types/cors": "^2.8.16", "@types/jsonwebtoken": "^9.0.5", "@types/multer": "^1.4.10", + "amqplib": "^0.10.3", "concurrently": "^8.2.2", "cors": "^2.8.5", "dotenv": "^16.3.1", @@ -34,11 +35,13 @@ "tsoa": "^5.1.1" }, "devDependencies": { + "@types/amqplib": "^0.10.4", "@types/express": "^4.17.21", "@types/node": "^20.9.0", "@types/swagger-ui-express": "^4.1.6", "nodemon": "^3.0.1", "ts-node": "^10.9.1", + "typedoc": "^0.25.3", "typescript": "^5.2.2" } } diff --git a/Services/server/pnpm-lock.yaml b/Services/server/pnpm-lock.yaml index 4bbe319..84c4764 100644 --- a/Services/server/pnpm-lock.yaml +++ b/Services/server/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: '@types/multer': specifier: ^1.4.10 version: 1.4.10 + amqplib: + specifier: ^0.10.3 + version: 0.10.3 concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -52,6 +55,9 @@ dependencies: version: 5.1.1 devDependencies: + '@types/amqplib': + specifier: ^0.10.4 + version: 0.10.4 '@types/express': specifier: ^4.17.21 version: 4.17.21 @@ -67,12 +73,26 @@ devDependencies: ts-node: specifier: ^10.9.1 version: 10.9.1(@types/node@20.9.0)(typescript@5.2.2) + typedoc: + specifier: ^0.25.3 + version: 0.25.3(typescript@5.2.2) typescript: specifier: ^5.2.2 version: 5.2.2 packages: + /@acuminous/bitsyntax@0.1.2: + resolution: {integrity: sha512-29lUK80d1muEQqiUsSo+3A0yP6CdspgC95EnKBMi22Xlwt79i/En4Vr67+cXhU+cZjbti3TgGGC5wy1stIywVQ==} + engines: {node: '>=0.8'} + dependencies: + buffer-more-ints: 1.0.0 + debug: 4.3.4 + safe-buffer: 5.1.2 + transitivePeerDependencies: + - supports-color + dev: false + /@babel/runtime@7.23.2: resolution: {integrity: sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==} engines: {node: '>=6.9.0'} @@ -181,6 +201,12 @@ packages: validator: 13.11.0 dev: false + /@types/amqplib@0.10.4: + resolution: {integrity: sha512-Y5Sqquh/LqDxSgxYaAAFNM0M7GyONtSDCcFMJk+DQwYEjibPyW6y+Yu9H9omdkKc3epyXULmFN3GTaeBHhn2Hg==} + dependencies: + '@types/node': 20.9.0 + dev: true + /@types/body-parser@1.19.5: resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} dependencies: @@ -295,11 +321,27 @@ packages: hasBin: true dev: true + /amqplib@0.10.3: + resolution: {integrity: sha512-UHmuSa7n8vVW/a5HGh2nFPqAEr8+cD4dEZ6u9GjP91nHfr1a54RyAKyra7Sb5NH7NBKOUlyQSMXIp0qAixKexw==} + engines: {node: '>=10'} + dependencies: + '@acuminous/bitsyntax': 0.1.2 + buffer-more-ints: 1.0.0 + readable-stream: 1.1.14 + url-parse: 1.5.10 + transitivePeerDependencies: + - supports-color + dev: false + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} dev: false + /ansi-sequence-parser@1.1.1: + resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==} + dev: true + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -430,7 +472,6 @@ packages: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} dependencies: balanced-match: 1.0.2 - dev: false /braces@3.0.2: resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} @@ -451,6 +492,10 @@ packages: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: false + /buffer-more-ints@1.0.0: + resolution: {integrity: sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==} + dev: false + /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1257,6 +1302,10 @@ packages: call-bind: 1.0.5 dev: false + /isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + dev: false + /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} dev: false @@ -1280,6 +1329,10 @@ packages: resolution: {integrity: sha512-H/ZGY0nIAg3QcOwE1QN/rK/Fa7gJn7Ii5obwp6zyPO4xiPNwpIMjqy2gwjBEGqzkF/vSWEIBQCBuN19hYiL6Qg==} dev: false + /jsonc-parser@3.2.0: + resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} + dev: true + /jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} dependencies: @@ -1299,10 +1352,20 @@ packages: yallist: 4.0.0 dev: true + /lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + dev: true + /make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} dev: true + /marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + dev: true + /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -1355,6 +1418,13 @@ packages: brace-expansion: 2.0.1 dev: false + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: false @@ -1566,6 +1636,10 @@ packages: strict-uri-encode: 2.0.0 dev: false + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: false + /range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1581,6 +1655,15 @@ packages: unpipe: 1.0.0 dev: false + /readable-stream@1.1.14: + resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + dev: false + /readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} dependencies: @@ -1631,6 +1714,10 @@ packages: engines: {node: '>=0.10.0'} dev: false + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: false + /rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} dependencies: @@ -1743,6 +1830,15 @@ packages: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} dev: false + /shiki@0.14.5: + resolution: {integrity: sha512-1gCAYOcmCFONmErGTrS1fjzJLA7MGZmKzrBNX7apqSwhyITJg2O102uFzXUeBxNnEkDA9vHIKLyeKq0V083vIw==} + dependencies: + ansi-sequence-parser: 1.1.1 + jsonc-parser: 3.2.0 + vscode-oniguruma: 1.7.0 + vscode-textmate: 8.0.0 + dev: true + /side-channel@1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: @@ -1832,6 +1928,10 @@ packages: es-abstract: 1.22.3 dev: false + /string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + dev: false + /string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} dependencies: @@ -2014,6 +2114,20 @@ packages: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} dev: false + /typedoc@0.25.3(typescript@5.2.2): + resolution: {integrity: sha512-Ow8Bo7uY1Lwy7GTmphRIMEo6IOZ+yYUyrc8n5KXIZg1svpqhZSWgni2ZrDhe+wLosFS8yswowUzljTAV/3jmWw==} + engines: {node: '>= 16'} + hasBin: true + peerDependencies: + typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x + dependencies: + lunr: 2.3.9 + marked: 4.3.0 + minimatch: 9.0.3 + shiki: 0.14.5 + typescript: 5.2.2 + dev: true + /typescript@4.9.5: resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'} @@ -2067,6 +2181,13 @@ packages: engines: {node: '>= 0.8'} dev: false + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: false @@ -2100,6 +2221,14 @@ packages: engines: {node: '>= 0.8'} dev: false + /vscode-oniguruma@1.7.0: + resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==} + dev: true + + /vscode-textmate@8.0.0: + resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} + dev: true + /web-encoding@1.1.5: resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} dependencies: diff --git a/Services/server/src/app.ts b/Services/server/src/app.ts index efefe83..10d2042 100644 --- a/Services/server/src/app.ts +++ b/Services/server/src/app.ts @@ -5,8 +5,10 @@ import cors from "cors"; import { RegisterRoutes } from "./routes"; import errorHandler from "./middlewares/exception"; +import rabbitmq from "./rabbitmq"; import swaggerSpecs from "./swagger.json"; +import { handler as amqHandler } from "./rabbitmq/handler"; const PORT = +(process.env.PORT || 80); @@ -26,5 +28,7 @@ app.use(swaggerSpecs.basePath, router); app.use(errorHandler); app.listen(PORT, "0.0.0.0", () => - console.log(`Application is running on http://localhost:${PORT}`), + console.log(`[APP] Application is running on http://localhost:${PORT}`), ); + +rabbitmq.init(amqHandler).catch((e) => console.error(e)); diff --git a/Services/server/src/controllers/cabinetController.ts b/Services/server/src/controllers/cabinetController.ts index 655ce60..3c3bf10 100644 --- a/Services/server/src/controllers/cabinetController.ts +++ b/Services/server/src/controllers/cabinetController.ts @@ -11,104 +11,141 @@ import { SuccessResponse, Tags, Request, + Response, + Example, } from "tsoa"; -import * as Minio from "minio"; -import minioClient from "../storage"; -import { EhrFile, EhrFolder } from "../interfaces/ehr-fs"; -import HttpStatusCode from "../interfaces/http-status"; -import { listFolder, listItem, replaceIllegalChars } from "../utils/minio"; +import minioClient from "../minio"; import esClient from "../elasticsearch"; +import { copyCond, listFolder, listItem, replaceIllegalChars } from "../utils/minio"; + +import HttpStatusCode from "../interfaces/http-status"; +import { StorageFile, StorageFolder } from "../interfaces/storage-fs"; + +const DEFAULT_BUCKET = process.env.MINIO_BUCKET; +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_BUCKET) throw Error("Default MinIO bucket must be specified."); +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); + @Route("cabinet") export class CabinetController extends Controller { @Get("/") - @Tags("Cabinet") - @SuccessResponse(HttpStatusCode.OK) - public async listCabinet(): Promise { - const list = await listFolder().catch((e) => console.error(`Error List Folder: ${e}`)); - - if (!list) { - throw new Error("Error listing folder"); - } - + @Tags("ตู้เอกสาร") + @Security("bearerAuth") + @Response( + HttpStatusCode.INTERNAL_SERVER_ERROR, + "เกิดข้อผิดพลาด ไม่สามารถแสดงรายการตู้เอกสารได้ กรุณาลองใหม่ในภายหลัง", + ) + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") + @Example([ + { + path: "ตู้เอกสาร 1/", + name: "ตู้เอกสาร 1", + createdAt: "2021-07-20T12:33:13.018Z", + createdBy: "admin", + }, + { + path: "ตู้เอกสาร 2/", + name: "ตู้เอกสาร 2", + createdAt: "2022-01-23T16:05:02.114Z", + createdBy: "admin", + }, + ]) + public async listCabinet(): Promise { + const list = await listFolder(DEFAULT_BUCKET!).catch((e) => + console.error(`Error List Folder: ${e}`), + ); + if (!list) + throw new Error("เกิดข้อผิดพลาด ไม่สามารถแสดงรายการตู้เอกสารได้ กรุณาลองใหม่ในภายหลัง"); return list; } @Post("/") - @Tags("Cabinet") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.CREATED) + @Tags("ตู้เอกสาร") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดกับระบบจัดการไฟล์") + @SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ") public async createCabinet( @Request() request: { user: { preferred_username: string } }, - @Body() body: { name: string }, + @Body() + body: { + /** + * @example "ตู้เอกสาร 1" + */ + name: string; + }, ) { - const uploaded = await minioClient - .putObject("ehr", `${replaceIllegalChars(body.name)}/.keep`, "", 0, { + const created = await minioClient + .putObject(DEFAULT_BUCKET!, `${replaceIllegalChars(body.name)}/.keep`, "", 0, { createdAt: new Date().toISOString(), createdBy: request.user.preferred_username, }) .catch((e) => console.error(e)); - if (!uploaded) throw new Error("Object storage error occured."); + if (!created) throw new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"); return this.setStatus(HttpStatusCode.CREATED); } + /** + * @example cabinetName "ตู้เอกสาร 1" + */ @Put("/{cabinetName}") - @Tags("Cabinet") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.NO_CONTENT, "Success") + @Tags("ตู้เอกสาร") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้") + @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async editCabinet( @Path() cabinetName: string, - @Body() body: { name: string }, + @Body() + body: { + /** + * @example "ตู้เอกสารใหม่" + */ + name: string; + }, ): Promise { - const list = await listItem(`${cabinetName}/`, true); - - const cond = new Minio.CopyConditions(); + const path = `${cabinetName}/`; + const list = await listItem(DEFAULT_BUCKET!, path, true); await Promise.all( list.map(async (current) => { if (!current.name) return; - const destination = `${replaceIllegalChars(body.name)}/${current.name.slice( - cabinetName.length + 1, - )}`; - const source = `/ehr/${current.name}`; + const destination = `${replaceIllegalChars(body.name)}/${current.name.slice(path.length)}`; + const source = `/${DEFAULT_BUCKET}/${current.name}`; return await minioClient - .copyObject("ehr", destination, source, cond) + .copyObject(DEFAULT_BUCKET!, destination, source, copyCond) .then(async () => { - if (!current.name) return; + if (current.name.includes(".keep")) { + return await minioClient.removeObject(DEFAULT_BUCKET!, current.name); + } - await minioClient.removeObject("ehr", current.name); - - if (current.name.includes(".keep")) return; - - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? "ehr-index", - query: { - match: { - pathname: current.name, - }, - }, + const search = await esClient.search< + StorageFile & { attachment: Record } + >({ + index: DEFAULT_INDEX!, + query: { match: { pathname: current.name } }, }); - if (search && search.hits.hits.length === 0) { - throw new Error("Data cannot be found in database."); - } + if (search && search.hits.hits.length === 0) throw new Error("ไม่พบข้อมูลในฐานข้อมูล"); const data = search.hits.hits[0]; await esClient.update({ - index: process.env.ELASTICSEARCH_INDEX ?? "ehr-index", + index: DEFAULT_INDEX!, id: data._id, doc: { pathname: destination }, }); + + await minioClient.removeObject(DEFAULT_BUCKET!, current.name); }) .catch((e) => { console.error(e); - throw new Error("Failed to move."); + throw new Error("เกิดข้อผิดพลาด ไม่สามารถย้ายไฟล์ได้"); }); }), ); @@ -116,25 +153,28 @@ export class CabinetController extends Controller { return this.setStatus(HttpStatusCode.NO_CONTENT); } + /** + * @example cabinetName "ตู้เอกสาร 1" + */ @Delete("/{cabinetName}") - @Tags("Cabinet") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.NO_CONTENT) + @Tags("ตู้เอกสาร") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาด ไม่สามารถลบไฟล์ได้") + @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async deleteCabinet(@Path() cabinetName: string) { - return new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const objects: string[] = []; - const stream = minioClient.listObjectsV2("ehr", `${cabinetName}/`, true); + const stream = minioClient.listObjectsV2(DEFAULT_BUCKET!, `${cabinetName}/`, true); stream.on("data", (v) => { - if (!(v && v.name)) return; - - objects.push(v.name); + if (v && v.name) objects.push(v.name); }); - - stream.on("close", () => minioClient.removeObjects("ehr", objects)); - stream.on("error", () => reject(new Error("Object storage error occured."))); - - resolve(this.setStatus(HttpStatusCode.NO_CONTENT)); + stream.on("close", async () => + resolve(await minioClient.removeObjects(DEFAULT_BUCKET!, objects)), + ); + stream.on("error", () => reject(new Error("เกิดข้อผิดพลาด ไม่สามารถลบไฟล์ได้"))); }); + + return this.setStatus(HttpStatusCode.NO_CONTENT); } } diff --git a/Services/server/src/controllers/drawerController.ts b/Services/server/src/controllers/drawerController.ts index 08b512e..746c045 100644 --- a/Services/server/src/controllers/drawerController.ts +++ b/Services/server/src/controllers/drawerController.ts @@ -6,120 +6,165 @@ import { Path, Post, Put, - Request, Route, Security, SuccessResponse, Tags, + Request, + Response, + Example, } from "tsoa"; -import * as Minio from "minio"; -import minioClient from "../storage"; + +import minioClient from "../minio"; +import esClient from "../elasticsearch"; + +import { copyCond, listFolder, listItem, pathExist, replaceIllegalChars } from "../utils/minio"; import HttpStatusCode from "../interfaces/http-status"; +import { StorageFile, StorageFolder } from "../interfaces/storage-fs"; import HttpError from "../interfaces/http-error"; -import { listFolder, listItem, pathExist, replaceIllegalChars } from "../utils/minio"; -import esClient from "../elasticsearch"; -import { EhrFile, EhrFolder } from "../interfaces/ehr-fs"; + +const DEFAULT_BUCKET = process.env.MINIO_BUCKET; +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_BUCKET) throw Error("Default MinIO bucket must be specified."); +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); @Route("/cabinet/{cabinetName}/drawer") export class DrawerController extends Controller { + /** + * @example cabinetName "ตู้เอกสาร 1" + */ @Get("/") - @Tags("Drawer") - @SuccessResponse(HttpStatusCode.OK) - public async listDrawer(@Path() cabinetName: string): Promise { - const fullpath = [cabinetName, ""].join("/"); - - if (!(await pathExist(fullpath))) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "Provided path does not exist."); - } - - return listFolder(fullpath); + @Tags("ลิ้นชัก") + @Security("bearerAuth") + @Response( + HttpStatusCode.INTERNAL_SERVER_ERROR, + "เกิดข้อผิดพลาด ไม่สามารถแสดงรายการลิ้นชักได้ กรุณาลองใหม่ในภายหลัง", + ) + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") + @Example([ + { + path: "ตู้เอกสาร 1/ลิ้นชัก 1/", + name: "ลิ้นชัก 1", + createdAt: "2021-07-20T12:33:13.018Z", + createdBy: "admin", + }, + { + path: "ตู้เอกสาร 1/ลิ้นชัก 2/", + name: "ลิ้นชัก 2", + createdAt: "2022-01-23T16:05:02.114Z", + createdBy: "admin", + }, + ]) + public async listDrawer(@Path() cabinetName: string): Promise { + const list = await listFolder(DEFAULT_BUCKET!, `${cabinetName}/`).catch((e) => + console.error(`Error List Folder: ${e}`), + ); + if (!list) + throw new Error("เกิดข้อผิดพลาด ไม่สามารถแสดงรายการลิ้นชักได้ กรุณาลองใหม่ในภายหลัง"); + return list; } + /** + * @example cabinetName "ตู้เอกสาร 1" + */ @Post("/") - @Tags("Drawer") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.CREATED) + @Tags("ลิ้นชัก") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.NOT_FOUND, "ไม่พบลิ้นชัก") + @Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดกับระบบจัดการไฟล์") + @SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ") public async createDrawer( @Request() request: { user: { preferred_username: string } }, @Path() cabinetName: string, - @Body() body: { name: string }, + @Body() + body: { + /** + * @example "ลิ้นชัก 1" + */ + name: string; + }, ) { - if (!(await pathExist(`${cabinetName}/`))) { - throw new HttpError(HttpStatusCode.PRECONDITION_FAILED, "Cabinet cannot be found."); + const basePath = `${cabinetName}/`; + + if (!(await pathExist(DEFAULT_BUCKET!, basePath))) { + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบตำแหน่งที่ต้องการสร้างลิ้นชัก"); } - const uploaded = await minioClient - .putObject("ehr", `${cabinetName}/${replaceIllegalChars(body.name)}/.keep`, "", 0, { + const created = await minioClient + .putObject(DEFAULT_BUCKET!, `${basePath}${replaceIllegalChars(body.name)}/.keep`, "", 0, { createdAt: new Date().toISOString(), createdBy: request.user.preferred_username, }) .catch((e) => console.error(e)); - if (!uploaded) { - throw new Error("Object storage error occured."); - } + if (!created) throw new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"); return this.setStatus(HttpStatusCode.CREATED); } + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + */ @Put("/{drawerName}") - @Tags("Drawer") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.NO_CONTENT) + @Tags("ลิ้นชัก") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้") + @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async editDrawer( @Path() cabinetName: string, @Path() drawerName: string, - @Body() body: { name: string }, + @Body() + body: { + /** + * @example "ลิ้นชักใหม่" + */ + name: string; + }, ): Promise { - const fullpath = `${cabinetName}/${drawerName}/`; - - const list = await listItem(fullpath, true); - - const cond = new Minio.CopyConditions(); + const path = `${cabinetName}/${drawerName}/`; + const list = await listItem(DEFAULT_BUCKET!, path, true); await Promise.all( list.map(async (current) => { if (!current.name) return; const destination = `${cabinetName}/${replaceIllegalChars(body.name)}/${current.name.slice( - fullpath.length, + path.length, )}`; - const source = `/ehr/${current.name}`; + const source = `/${DEFAULT_BUCKET}/${current.name}`; return await minioClient - .copyObject("ehr", destination, source, cond) + .copyObject(DEFAULT_BUCKET!, destination, source, copyCond) .then(async () => { - if (!current.name) return; + if (current.name.includes(".keep")) { + return await minioClient.removeObject(DEFAULT_BUCKET!, current.name); + } - await minioClient.removeObject("ehr", current.name); - - if (current.name.includes(".keep")) return; - - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? "ehr-index", - query: { - match: { - pathname: current.name, - }, - }, + const search = await esClient.search< + StorageFile & { attachment: Record } + >({ + index: DEFAULT_INDEX!, + query: { match: { pathname: current.name } }, }); - if (search && search.hits.hits.length === 0) { - throw new Error("Data cannot be found in database."); - } + if (search && search.hits.hits.length === 0) throw new Error("ไม่พบข้อมูลในฐานข้อมูล"); const data = search.hits.hits[0]; await esClient.update({ - index: process.env.ELASTICSEARCH_INDEX ?? "ehr-index", + index: DEFAULT_INDEX!, id: data._id, doc: { pathname: destination }, }); + + await minioClient.removeObject(DEFAULT_BUCKET!, current.name); }) .catch((e) => { console.error(e); - throw new Error("Failed to move."); + throw new Error("เกิดข้อผิดพลาด ไม่สามารถย้ายไฟล์ได้"); }); }), ); @@ -127,25 +172,32 @@ export class DrawerController extends Controller { return this.setStatus(HttpStatusCode.NO_CONTENT); } + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + */ @Delete("/{drawerName}") - @Tags("Drawer") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.NO_CONTENT) + @Tags("ลิ้นชัก") + @Security("bearerAuth", ["admin"]) + @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async deleteDrawer(@Path() cabinetName: string, @Path() drawerName: string) { - return new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const objects: string[] = []; - const stream = minioClient.listObjectsV2("ehr", `${cabinetName}/${drawerName}/`, true); + const stream = minioClient.listObjectsV2( + DEFAULT_BUCKET!, + `${cabinetName}/${drawerName}/`, + true, + ); stream.on("data", (v) => { - if (!(v && v.name)) return; - - objects.push(v.name); + if (v && v.name) objects.push(v.name); }); - - stream.on("close", () => minioClient.removeObjects("ehr", objects)); - stream.on("error", () => reject(new Error("Object storage error occured."))); - - resolve(true); + stream.on("close", async () => + resolve(await minioClient.removeObjects(DEFAULT_BUCKET!, objects)), + ); + stream.on("error", () => reject(new Error("เกิดข้อผิดพลาด ไม่สามารถลบไฟล์ได้"))); }); + + return this.setStatus(HttpStatusCode.NO_CONTENT); } } diff --git a/Services/server/src/controllers/fileController.ts b/Services/server/src/controllers/fileController.ts index 6fb9393..4ecf5cb 100644 --- a/Services/server/src/controllers/fileController.ts +++ b/Services/server/src/controllers/fileController.ts @@ -1,299 +1,280 @@ import { + Body, Controller, Delete, - FormField, Get, Patch, Path, Post, Request, + Response, Route, Security, SuccessResponse, Tags, - UploadedFile, } from "tsoa"; + import esClient from "../elasticsearch"; -import minioClient from "../storage"; +import minioClient from "../minio"; + import HttpStatusCode from "../interfaces/http-status"; -import { pathExist } from "../utils/minio"; +import { StorageFile } from "../interfaces/storage-fs"; import HttpError from "../interfaces/http-error"; -import { EhrFile } from "../interfaces/ehr-fs"; + +import { copyCond, pathExist } from "../utils/minio"; + +const DEFAULT_BUCKET = process.env.MINIO_BUCKET; +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_BUCKET) throw Error("Default MinIO bucket must be specified."); +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); @Route("/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/file") export class FileController extends Controller { - @Post("/") - @Tags("File") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.CREATED) - public async uploadFile( - @Request() request: { user: { preferred_username: string } }, - @UploadedFile() file: Express.Multer.File, - @FormField() title: string, - @FormField() description: string, - @FormField() keyword: string, - @FormField() category: string, - @Path() cabinetName: string, - @Path() drawerName: string, - @Path() folderName: string, - ) { - const filename = Buffer.from(file.originalname, "latin1").toString("utf-8"); - const pathname = `${cabinetName}/${drawerName}/${folderName}/${filename}`; - - if (!(await pathExist(`${cabinetName}/${drawerName}/${folderName}/`))) { - throw new HttpError( - HttpStatusCode.PRECONDITION_FAILED, - "Cabinet, drawer or folder cannot be found.", - ); - } - - const info = await minioClient - .putObject("ehr", pathname, file.buffer, file.size, { - "Content-Type": file.mimetype, - createdAt: new Date().toISOString(), - createdBy: request.user.preferred_username, - }) - .catch((e) => console.error(e)); - - if (!info) throw new Error("Object storage error occured."); - - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - query: { - match: { - pathname: pathname, - }, - }, - }); - - const exist = search.hits.hits.find((v) => v._source?.pathname === pathname); - - const metadata: Partial = { - pathname, - fileName: filename, - fileSize: file.size, - fileType: file.mimetype, - title: title, - description: description, - category: category.split(","), - keyword: keyword.split(","), - }; - - if (!exist) { - await esClient.index({ - pipeline: "attachment", - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - document: { - data: Buffer.from(file.buffer).toString("base64"), - createdAt: new Date().toISOString(), - createdBy: request.user.preferred_username, - updatedAt: new Date().toISOString(), - updatedBy: request.user.preferred_username, - ...metadata, - }, - }); - } else { - await esClient.delete({ index: exist._index, id: exist._id }); - await esClient.index({ - pipeline: "attachment", - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - document: { - data: Buffer.from(file.buffer).toString("base64"), - createdAt: exist._source?.createdAt, - createdBy: exist._source?.createdBy, - updatedAt: new Date().toISOString(), - updatedBy: request.user.preferred_username, - ...metadata, - }, - }); - } - - return this.setStatus(HttpStatusCode.CREATED); - } - @Get("/") - @Tags("File") - @SuccessResponse(HttpStatusCode.OK) + @Tags("ไฟล์") + @Security("bearerAuth") + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async getFile( @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, - ): Promise { - const search = await esClient.search< - EhrFile & { - attachment: Record; - } - >({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', + ): Promise { + const search = await esClient.search }>({ + index: DEFAULT_INDEX!, query: { prefix: { pathname: `${cabinetName}/${drawerName}/${folderName}/`, }, }, + size: 10000, }); - // Use flatMap for return type only. Filter does not change type after filter out undefined or null const records = search.hits.hits .map((v) => { - if (!v._source) return; - - const { attachment, ...rest } = v._source; - - return rest; + if (v._source) { + const { attachment, ...rest } = v._source; + return rest satisfies StorageFile; + } }) - .flatMap((v) => (v ? [v] : [])); + .filter((v: StorageFile | undefined): v is StorageFile => !!v); return records; } + @Post("/") + @Tags("ไฟล์") + @Security("bearerAuth", ["admin"]) + @Response( + HttpStatusCode.NOT_FOUND, + "ตำแหน่งที่ระบุไม่พบ กรุณาเตรียมตำแหน่งที่ต้องการก่อนดำเนินการ", + ) + @SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ") + public async uploadFile( + @Request() request: { user: { preferred_username: string } }, + @Body() + body: { + file: string; + title?: string; + description?: string; + category?: string; + keyword?: string; + }, + @Path() cabinetName: string, + @Path() drawerName: string, + @Path() folderName: string, + ) { + const basePath = `${cabinetName}/${drawerName}/${folderName}/`; + const pathname = `${basePath}${body.file}`; + + if (!(await pathExist(DEFAULT_BUCKET!, basePath))) { + throw new HttpError( + HttpStatusCode.NOT_FOUND, + "ตำแหน่งที่ระบุไม่พบ กรุณาเตรียมตำแหน่งที่ต้องการก่อนดำเนินการ", + ); + } + + const result = await esClient + .search }>({ + index: DEFAULT_INDEX!, + query: { match: { pathname } }, + }) + .catch((e) => console.error(e)); + + // pathname is unique and should not have multiple record with same path + if (result && result.hits.hits.length > 0 && result.hits.hits[0]._source) { + await esClient + .delete({ + index: DEFAULT_INDEX!, + id: result.hits.hits[0]._id, + }) + .catch((e) => console.error(e)); + } + + const rec = result ? result.hits.hits[0]._source : false; + + const metadata: Partial = { + pathname, + fileName: body.file, + fileSize: 0, + fileType: "", + title: body.title ?? "", + description: body.description ?? "", + category: body.category?.split(",") ?? [], + keyword: body.keyword?.split(",") ?? [], + upload: false, + createdAt: new Date().toISOString(), + createdBy: rec ? rec.createdBy : "n/a", + updatedAt: new Date().toISOString(), + updatedBy: request.user.preferred_username ?? "n/a", + }; + + await esClient.index({ + index: DEFAULT_INDEX!, + document: metadata, + }); + + return { + ...body, + createdAt: metadata.createdAt, + createdBy: metadata.createdBy, + updatedAt: metadata.updatedAt, + updatedBy: metadata.updatedBy, + upload: await minioClient.presignedPutObject(DEFAULT_BUCKET!, pathname), + }; + } + @Patch("/{fileName}") - @Tags("File") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.OK) + @Tags("ไฟล์") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.NOT_FOUND, "ไม่พบตำแหน่งที่ต้องการสร้างแฟ้ม") + @Response(HttpStatusCode.NO_CONTENT, "สำเร็จ") + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async updateFile( @Request() request: { user: { preferred_username: string } }, @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, @Path() fileName: string, - @UploadedFile() file?: Express.Multer.File, - @FormField() title?: string, - @FormField() description?: string, - @FormField() keyword?: string, - @FormField() category?: string, - ) { - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - query: { - match: { - pathname: `${cabinetName}/${drawerName}/${folderName}/${fileName}`, - }, - }, - }); + @Body() + body: { + file?: string; + title?: string; + description?: string; + category?: string; + keyword?: string; + }, + ): Promise { + const basePath = `${cabinetName}/${drawerName}/${folderName}/`; + const pathname = `${basePath}${fileName}`; - if (search && search.hits.hits.length === 0) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "Not found"); + if ( + !Boolean( + await minioClient.statObject(DEFAULT_BUCKET!, `${pathname}`).catch((e) => { + if (e.code === "NotFound") return false; + throw new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"); + }), + ) + ) { + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบไฟล์"); } - const data = search.hits.hits[0]; + // assume user will probably replace file by re-upload but maybe just rename + if (body.file) { + const destination = `${basePath}${body.file}`; + const source = `/${DEFAULT_BUCKET}/${basePath}${fileName}`; + const copy = await minioClient.copyObject(DEFAULT_BUCKET!, destination, source, copyCond); - if (!file) { - const esResult = await esClient - .update({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - id: data._id, - doc: { - title, - description, - keyword: keyword?.split(","), - category: category?.split(","), - updatedAt: new Date().toISOString(), - updatedBy: request.user.preferred_username, - }, - }) - .catch((e) => console.error(e)); + if (copy) { + const search = await esClient + .search }>({ + index: DEFAULT_INDEX!, + query: { match: { pathname } }, + }) + .catch((e) => console.error(e)); - if (!esResult) throw new Error("An error occured, cannot perform this action."); + if (search && search.hits.hits.length > 0 && search.hits.hits[0]._source) { + const { _index: index, _id: id } = search.hits.hits[0]; + await esClient + .update({ + index, + id, + doc: { + pathname: destination, + updatedAt: new Date().toISOString(), + updatedBy: request.user.preferred_username ?? "n/a", + }, + }) + .then(() => minioClient.removeObject(DEFAULT_BUCKET!, pathname)); + } else { + await minioClient.removeObject(DEFAULT_BUCKET!, pathname); + } + } } else { - const filename = Buffer.from(file.originalname, "latin1").toString("utf-8"); - const pathname = `${cabinetName}/${drawerName}/${folderName}/${filename}`; - - await minioClient.removeObject( - "ehr", - `${cabinetName}/${drawerName}/${folderName}/${fileName}`, - ); - - const info = await minioClient - .putObject("ehr", pathname, file.buffer, file.size, { - "Content-Type": file.mimetype, - createdAt: new Date().toISOString(), - createdBy: request.user.preferred_username, + const search = await esClient + .search }>({ + index: DEFAULT_INDEX!, + query: { match: { pathname } }, }) .catch((e) => console.error(e)); - if (!info) throw new Error("Object storage error occured."); - - await esClient.delete({ index: data._index, id: data._id }); - await esClient.index({ - pipeline: "attachment", - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - document: { - data: Buffer.from(file.buffer).toString("base64"), - pathname, - fileName: filename, - fileSize: file.size, - fileType: file.mimetype, - title: title, - description: description, - category: category?.split(","), - keyword: keyword?.split(","), - createdAt: data._source?.createdAt, - createdBy: data._source?.createdBy, - updatedAt: new Date().toISOString(), - updatedBy: request.user.preferred_username, - }, - }); + if (search && search.hits.hits.length > 0 && search.hits.hits[0]._source) { + const { _index: index, _id: id } = search.hits.hits[0]; + await esClient.update({ + index, + id, + doc: { + ...body, + keyword: body.keyword?.split(","), + category: body.category?.split(","), + updatedAt: new Date().toISOString(), + updatedBy: request.user.preferred_username ?? "n/a", + }, + }); + } } - return this.setStatus(HttpStatusCode.NO_CONTENT); + return body.file + ? { + upload: await minioClient.presignedPutObject( + DEFAULT_BUCKET!, + `${basePath}${body.file ?? fileName}`, + ), + } + : this.setStatus(HttpStatusCode.NO_CONTENT); } @Delete("/{fileName}") - @Tags("File") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.OK) + @Tags("ไฟล์") + @Security("bearerAuth", ["admin"]) + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async deleteFile( @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, @Path() fileName: string, ) { - const search = await esClient.search< - EhrFile & { - attachment: Record; - } - >({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - query: { - match: { - pathname: `${cabinetName}/${drawerName}/${folderName}/${fileName}`, - }, - }, - }); - - if (search && search.hits.hits.length === 0) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "Not found"); - } - - const esResult = await esClient - .delete({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - id: search.hits.hits[0]._id, - }) - .catch((e) => console.error(e)); - - if (!esResult) throw new Error("An error occured, cannot perform this action."); - - await minioClient.removeObject("ehr", `${cabinetName}/${drawerName}/${folderName}/${fileName}`); - + await minioClient.removeObject( + DEFAULT_BUCKET!, + `${cabinetName}/${drawerName}/${folderName}/${fileName}`, + ); return this.setStatus(HttpStatusCode.NO_CONTENT); } @Get("/{fileName}") - @Tags("File") - @SuccessResponse(HttpStatusCode.OK) + @Tags("ดาวน์โหลด") + @Security("bearerAuth") + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async downloadFile( @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, @Path() fileName: string, ) { - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', + const search = await esClient.search }>({ + index: DEFAULT_INDEX!, query: { - match: { - pathname: `${cabinetName}/${drawerName}/${folderName}/${fileName}`, - }, + match: { pathname: `${cabinetName}/${drawerName}/${folderName}/${fileName}` }, }, }); @@ -301,18 +282,12 @@ export class FileController extends Controller { throw new HttpError(HttpStatusCode.NOT_FOUND, "Not found"); } - const data = search.hits.hits[0]._source; - - if (!data) { - throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "Found data but no info."); - } - - const { attachment, ...rest } = data; + const { attachment, ...rest } = search.hits.hits[0]._source!; return { ...rest, download: await minioClient.presignedGetObject( - "ehr", + DEFAULT_BUCKET!, `${cabinetName}/${drawerName}/${folderName}/${fileName}`, ), }; diff --git a/Services/server/src/controllers/folderController.ts b/Services/server/src/controllers/folderController.ts index a0bb938..b0e5abf 100644 --- a/Services/server/src/controllers/folderController.ts +++ b/Services/server/src/controllers/folderController.ts @@ -6,88 +6,133 @@ import { Path, Post, Put, - Request, Route, Security, SuccessResponse, Tags, + Request, + Response, + Example, } from "tsoa"; -import * as Minio from "minio"; -import HttpError from "../interfaces/http-error"; -import HttpStatusCode from "../interfaces/http-status"; -import { listFolder, listItem, pathExist, replaceIllegalChars } from "../utils/minio"; -import { EhrFile, EhrFolder } from "../interfaces/ehr-fs"; -import minioClient from "../storage"; +import minioClient from "../minio"; import esClient from "../elasticsearch"; +import { copyCond, listFolder, listItem, pathExist, replaceIllegalChars } from "../utils/minio"; + +import HttpStatusCode from "../interfaces/http-status"; +import { StorageFile, StorageFolder } from "../interfaces/storage-fs"; +import HttpError from "../interfaces/http-error"; + +const DEFAULT_BUCKET = process.env.MINIO_BUCKET; +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_BUCKET) throw Error("Default MinIO bucket must be specified."); +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); + @Route("/cabinet/{cabinetName}/drawer/{drawerName}/folder") export class FolderController extends Controller { + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + */ @Get("/") - @Tags("Folder") - @SuccessResponse(HttpStatusCode.OK) + @Tags("แฟ้ม") + @Security("bearerAuth") + @Response( + HttpStatusCode.INTERNAL_SERVER_ERROR, + "เกิดข้อผิดพลาด ไม่สามารถแสดงรายการแฟ้มได้ กรุณาลองใหม่ในภายหลัง", + ) + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") + @Example([ + { + path: "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 1", + name: "แฟ้ม 1", + createdAt: "2021-07-20T12:33:13.018Z", + createdBy: "admin", + }, + { + path: "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 2", + name: "แฟ้ม 2", + createdAt: "2022-01-23T16:05:02.114Z", + createdBy: "admin", + }, + ]) public async listFolder( @Path() cabinetName: string, @Path() drawerName: string, - ): Promise { - const fullpath = [cabinetName, drawerName, ""].join("/"); - - if (!(await pathExist(fullpath))) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "Provided path does not exist."); - } - - return listFolder(fullpath); + ): Promise { + const list = await listFolder(DEFAULT_BUCKET!, `${cabinetName}/${drawerName}`).catch((e) => + console.error(`Error List Folder: ${e}`), + ); + if (!list) throw new Error("เกิดข้อผิดพลาด ไม่สามารถแสดงรายการแฟ้มได้ กรุณาลองใหม่ในภายหลัง"); + return list; } + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + */ @Post("/") - @Tags("Folder") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.CREATED) + @Tags("แฟ้ม") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.NOT_FOUND, "ไม่พบตำแหน่งที่ต้องการสร้างแฟ้ม") + @Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดกับระบบจัดการไฟล์") + @SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ") public async createFolder( @Request() request: { user: { preferred_username: string } }, - @Body() body: { name: string }, + @Body() + body: { + /** + * @example "แฟ้ม 1" + */ + name: string; + }, @Path() cabinetName: string, @Path() drawerName: string, ) { - if (!(await pathExist(`${cabinetName}/${drawerName}/`))) { - throw new HttpError(HttpStatusCode.PRECONDITION_FAILED, "Cabinet or drawer cannot be found."); + const basePath = `${cabinetName}/${drawerName}/`; + + if (!(await pathExist(DEFAULT_BUCKET!, basePath))) { + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบตำแหน่งที่ต้องการสร้างลิ้นชัก"); } - const uploaded = await minioClient - .putObject( - "ehr", - `${cabinetName}/${drawerName}/${replaceIllegalChars(body.name)}/.keep`, - "", - 0, - { - createdAt: new Date().toISOString(), - createdBy: request.user.preferred_username, - }, - ) + const created = await minioClient + .putObject(DEFAULT_BUCKET!, `${basePath}${replaceIllegalChars(body.name)}/.keep`, "", 0, { + createdAt: new Date().toISOString(), + createdBy: request.user.preferred_username, + }) .catch((e) => console.error(e)); - if (!uploaded) { - throw new Error("Object storage error occured."); - } + if (!created) throw new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"); return this.setStatus(HttpStatusCode.CREATED); } + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + * @example folderName "แฟ้ม 1" + */ @Put("/{folderName}") - @Tags("Folder") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.NO_CONTENT) + @Tags("แฟ้ม") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้") + @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async editFolder( - @Body() body: { name: string }, + @Body() + body: { + /** + * @example "แฟ้มใหม่" + */ + name: string; + }, @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, ) { - const fullpath = `${cabinetName}/${drawerName}/${folderName}`; - - const list = await listItem(fullpath, true); - - const cond = new Minio.CopyConditions(); + const path = `${cabinetName}/${drawerName}/${folderName}`; + const list = await listItem(DEFAULT_BUCKET!, path, true); await Promise.all( list.map(async (current) => { @@ -95,42 +140,38 @@ export class FolderController extends Controller { const destination = `${cabinetName}/${drawerName}/${replaceIllegalChars( body.name, - )}/${current.name.slice(fullpath.length)}`; - const source = `/ehr/${current.name}`; + )}/${current.name.slice(path.length)}`; + const source = `/${DEFAULT_BUCKET}/${current.name}`; return await minioClient - .copyObject("ehr", destination, source, cond) + .copyObject(DEFAULT_BUCKET!, destination, source, copyCond) .then(async () => { - if (!current.name) return; + if (current.name.includes(".keep")) { + return await minioClient.removeObject(DEFAULT_BUCKET!, current.name); + } - await minioClient.removeObject("ehr", current.name); - - if (current.name.includes(".keep")) return; - - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? "ehr-index", - query: { - match: { - pathname: current.name, - }, - }, + const search = await esClient.search< + StorageFile & { attachment: Record } + >({ + index: DEFAULT_INDEX!, + query: { match: { pathname: current.name } }, }); - if (search && search.hits.hits.length === 0) { - throw new Error("Data cannot be found in database."); - } + if (search && search.hits.hits.length === 0) throw new Error("ไม่พบข้อมูลในฐานข้อมูล"); const data = search.hits.hits[0]; await esClient.update({ - index: process.env.ELASTICSEARCH_INDEX ?? "ehr-index", + index: DEFAULT_INDEX!, id: data._id, doc: { pathname: destination }, }); + + await minioClient.removeObject(DEFAULT_BUCKET!, current.name); }) .catch((e) => { console.error(e); - throw new Error("Failed to move."); + throw new Error("เกิดข้อผิดพลาด ไม่สามารถย้ายไฟล์ได้"); }); }), ); @@ -138,33 +179,37 @@ export class FolderController extends Controller { return this.setStatus(HttpStatusCode.NO_CONTENT); } + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + * @example folderName "แฟ้ม 1" + */ @Delete("/{folderName}") - @Tags("Folder") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.NO_CONTENT) + @Tags("แฟ้ม") + @Security("bearerAuth", ["admin"]) + @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async deleteFolder( @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, ) { - return new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const objects: string[] = []; const stream = minioClient.listObjectsV2( - "ehr", + DEFAULT_BUCKET!, `${cabinetName}/${drawerName}/${folderName}`, true, ); stream.on("data", (v) => { - if (!(v && v.name)) return; - - objects.push(v.name); + if (v && v.name) objects.push(v.name); }); - - stream.on("close", () => minioClient.removeObjects("ehr", objects)); - stream.on("error", () => reject(new Error("Object storage error occured."))); - - resolve(this.setStatus(HttpStatusCode.NO_CONTENT)); + stream.on("close", async () => + resolve(await minioClient.removeObjects(DEFAULT_BUCKET!, objects)), + ); + stream.on("error", () => reject(new Error("เกิดข้อผิดพลาด ไม่สามารถลบไฟล์ได้"))); }); + + return this.setStatus(HttpStatusCode.NO_CONTENT); } } diff --git a/Services/server/src/controllers/searchController.ts b/Services/server/src/controllers/searchController.ts index 5edb848..0a6c669 100644 --- a/Services/server/src/controllers/searchController.ts +++ b/Services/server/src/controllers/searchController.ts @@ -1,23 +1,29 @@ -import { Body, Controller, Post, Route, SuccessResponse, Tags } from "tsoa"; +import { Body, Controller, Post, Route, Security, SuccessResponse, Tags } from "tsoa"; import HttpStatusCode from "../interfaces/http-status"; import esClient from "../elasticsearch"; import { Search } from "../interfaces/search"; -import { EhrFile } from "../interfaces/ehr-fs"; +import { StorageFile } from "../interfaces/storage-fs"; + +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); @Route("/search") export class SearchController extends Controller { @Post("/") - @Tags("Search") - @SuccessResponse(HttpStatusCode.OK) - public async searchFile(@Body() search: Search): Promise { - const result = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', + @Tags("ค้นหา") + @Security("bearerAuth") + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") + public async searchFile(@Body() search: Search): Promise { + const result = await esClient.search }>({ + index: DEFAULT_INDEX, query: { bool: { must: search.AND?.map((v) => ({ match: { [v.field]: v.value } })), should: search.OR?.map((v) => ({ match: { [v.field]: v.value } })), }, }, + size: 10000, }); return result.hits.hits.length > 0 diff --git a/Services/server/src/controllers/storageController.ts b/Services/server/src/controllers/storageController.ts new file mode 100644 index 0000000..4a1c32d --- /dev/null +++ b/Services/server/src/controllers/storageController.ts @@ -0,0 +1,104 @@ +import { Body, Controller, Get, Path, Post, Put, Query, Route, SuccessResponse } from "tsoa"; +import { replaceIllegalChars } from "../utils/minio"; +import minioClient from "../minio"; +import HttpStatusCode from "../interfaces/http-status"; +import HttpError from "../interfaces/http-error"; + +const DEFAULT_BUCKET = process.env.MINIO_BUCKET; +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_BUCKET) throw Error("Default MinIO bucket must be specified."); +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); + +@Route("/storage/d") +export class StorageController extends Controller { + @Get() + @SuccessResponse(HttpStatusCode.OK, "Success") + public async getFolder(@Query() path: string, @Query() bucket?: string) { + const list = await new Promise<{ pathname: string; name: string }[]>((resolve, reject) => { + const item: { pathname: string; name: string }[] = []; + + const stream = minioClient.listObjectsV2(bucket ?? DEFAULT_BUCKET!, path); + stream.on("data", (v) => { + if (v && v.prefix) { + item.push({ + pathname: v.prefix, + name: v.prefix.slice(path?.length).split("/")[0], + }); + } + }); + stream.on("end", () => resolve(item)); + stream.on("error", () => reject(new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"))); + }); + + return list; + } + + @Post() + @SuccessResponse(HttpStatusCode.CREATED, "Success") + public async createFolder(@Query() path: string, @Query() bucket?: string) { + const fragments = path.split("/").filter(Boolean); + + await Promise.all( + fragments.map(async (_, i, a) => { + const path = [...a.slice(0, i + 1)].map((x) => replaceIllegalChars(x)).join("/"); + const created = await minioClient + .putObject(bucket ?? DEFAULT_BUCKET!, `${path}/.keep`, "", 0) + .catch((e) => console.error(e)); + if (!created) throw new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"); + }), + ); + + return this.setStatus(HttpStatusCode.CREATED); + } + + @Put() + @SuccessResponse(HttpStatusCode.NO_CONTENT, "Success") + public async updateFolder( + @Body() + body: { + from: { + bucket: string; + path: string; + }; + to: { + bucket: string; + path: string; + }; + }, + ) { + const src = body.from.path.split("/").filter(Boolean).join("/"); + const dst = body.to.path.split("/").filter(Boolean).join("/"); + + if ( + !Boolean( + await minioClient + .statObject(DEFAULT_BUCKET!, `${dst.replace(/^\/|\/$/g, "")}/.keep`) + .catch((e) => { + if (e.code === "NotFound") return false; + throw new Error(`Minio Error: ${e}`); + }), + ) + ) + throw new HttpError(HttpStatusCode.NOT_FOUND, "Destination Not Found"); + + const list = await new Promise<{ pathname: string; name: string }[]>((resolve, reject) => { + const item: { pathname: string; name: string }[] = []; + + const stream = minioClient.listObjectsV2(body.from.bucket, src); + + stream.on("data", (v) => { + if (v && v.prefix) { + item.push({ + pathname: v.prefix, + name: v.prefix.slice(src.length).split("/")[0], + }); + } + }); + stream.on("error", (e) => reject(new Error(`Minio Error: ${e}`))); + stream.on("end", () => resolve(item)); + }); + + return this.setStatus(HttpStatusCode.NO_CONTENT); + } +} diff --git a/Services/server/src/controllers/subFolderController.ts b/Services/server/src/controllers/subFolderController.ts index 2ab4356..b748d16 100644 --- a/Services/server/src/controllers/subFolderController.ts +++ b/Services/server/src/controllers/subFolderController.ts @@ -6,44 +6,83 @@ import { Path, Post, Put, - Request, Route, Security, SuccessResponse, Tags, + Request, + Response, + Example, } from "tsoa"; -import * as Minio from "minio"; -import HttpError from "../interfaces/http-error"; -import HttpStatusCode from "../interfaces/http-status"; -import { listFolder, listItem, pathExist, replaceIllegalChars } from "../utils/minio"; -import { EhrFile, EhrFolder } from "../interfaces/ehr-fs"; -import minioClient from "../storage"; +import minioClient from "../minio"; import esClient from "../elasticsearch"; +import { copyCond, listFolder, listItem, pathExist, replaceIllegalChars } from "../utils/minio"; + +import HttpStatusCode from "../interfaces/http-status"; +import { StorageFile, StorageFolder } from "../interfaces/storage-fs"; +import HttpError from "../interfaces/http-error"; + +const DEFAULT_BUCKET = process.env.MINIO_BUCKET; +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_BUCKET) throw Error("Default MinIO bucket must be specified."); +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); + @Route("/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/subfolder") export class SubFolderController extends Controller { + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + * @example folderName "แฟ้ม 1" + */ @Get("/") - @Tags("SubFolder") - @SuccessResponse(HttpStatusCode.OK) + @Tags("แฟ้มย่อย") + @Security("bearerAuth") + @Response( + HttpStatusCode.INTERNAL_SERVER_ERROR, + "เกิดข้อผิดพลาด ไม่สามารถแสดงรายการแฟ้มได้ กรุณาลองใหม่ในภายหลัง", + ) + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") + @Example([ + { + path: "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 1/แฟ้มย่อย 1", + name: "แฟ้มย่อย 1", + createdAt: "2021-07-20T12:33:13.018Z", + createdBy: "admin", + }, + { + path: "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 1/แฟ้มย่อย 2", + name: "แฟ้มย่อย 2", + createdAt: "2022-01-23T16:05:02.114Z", + createdBy: "admin", + }, + ]) public async listFolder( @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, - ): Promise { - const fullpath = [cabinetName, drawerName, folderName, ""].join("/"); - - if (!(await pathExist(fullpath))) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "Provided path does not exist."); - } - - return listFolder(fullpath); + ): Promise { + const list = await listFolder( + DEFAULT_BUCKET!, + `${cabinetName}/${drawerName}/${folderName}`, + ).catch((e) => console.error(`Error List Folder: ${e}`)); + if (!list) throw new Error("เกิดข้อผิดพลาด ไม่สามารถแสดงรายการแฟ้มได้ กรุณาลองใหม่ในภายหลัง"); + return list; } + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + * @example folderName "แฟ้ม 1" + */ @Post("/") - @Tags("SubFolder") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.CREATED) + @Tags("แฟ้มย่อย") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.NOT_FOUND, "ไม่พบของแฟ้ม") + @Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดกับระบบจัดการไฟล์") + @SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ") public async createFolder( @Request() request: { user: { preferred_username: string } }, @Body() body: { name: string }, @@ -51,49 +90,50 @@ export class SubFolderController extends Controller { @Path() drawerName: string, @Path() folderName: string, ) { - if (!(await pathExist(`${cabinetName}/${drawerName}/${folderName}`))) { - throw new HttpError( - HttpStatusCode.PRECONDITION_FAILED, - "Cabinet, drawer or folder cannot be found.", - ); + const basePath = `${cabinetName}/${drawerName}/${folderName}/`; + + if (!(await pathExist(DEFAULT_BUCKET!, basePath))) { + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบตำแหน่งที่ต้องการสร้างลิ้นชัก"); } - const uploaded = await minioClient - .putObject( - "ehr", - `${cabinetName}/${drawerName}/${folderName}/${replaceIllegalChars(body.name)}/.keep`, - "", - 0, - { - createdAt: new Date().toISOString(), - createdBy: request.user.preferred_username, - }, - ) + const created = await minioClient + .putObject(DEFAULT_BUCKET!, `${basePath}${replaceIllegalChars(body.name)}/.keep`, "", 0, { + createdAt: new Date().toISOString(), + createdBy: request.user.preferred_username, + }) .catch((e) => console.error(e)); - if (!uploaded) { - throw new Error("Object storage error occured."); - } + if (!created) throw new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"); return this.setStatus(HttpStatusCode.CREATED); } + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + * @example folderName "แฟ้ม 1" + * @example subFolderName "แฟ้มย่อย 1" + */ @Put("/{subFolderName}") - @Tags("SubFolder") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.NO_CONTENT) + @Tags("แฟ้มย่อย") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.INTERNAL_SERVER_ERROR, "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้") + @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async editFolder( - @Body() body: { name: string }, + @Body() + body: { + /** + * @example "แฟ้มใหม่" + */ + name: string; + }, @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, @Path() subFolderName: string, ) { - const fullpath = `${cabinetName}/${drawerName}/${folderName}/${subFolderName}`; - - const list = await listItem(fullpath, true); - - const cond = new Minio.CopyConditions(); + const path = `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/`; + const list = await listItem(DEFAULT_BUCKET!, path, true); await Promise.all( list.map(async (current) => { @@ -101,42 +141,38 @@ export class SubFolderController extends Controller { const destination = `${cabinetName}/${drawerName}/${folderName}/${replaceIllegalChars( body.name, - )}/${current.name.slice(fullpath.length)}`; - const source = `/ehr/${current.name}`; + )}/${current.name.slice(path.length)}`; + const source = `/${DEFAULT_BUCKET}/${current.name}`; return await minioClient - .copyObject("ehr", destination, source, cond) + .copyObject(DEFAULT_BUCKET!, destination, source, copyCond) .then(async () => { - if (!current.name) return; + if (current.name.includes(".keep")) { + return await minioClient.removeObject(DEFAULT_BUCKET!, current.name); + } - await minioClient.removeObject("ehr", current.name); - - if (current.name.includes(".keep")) return; - - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? "ehr-index", - query: { - match: { - pathname: current.name, - }, - }, + const search = await esClient.search< + StorageFile & { attachment: Record } + >({ + index: DEFAULT_INDEX!, + query: { match: { pathname: current.name } }, }); - if (search && search.hits.hits.length === 0) { - throw new Error("Data cannot be found in database."); - } + if (search && search.hits.hits.length === 0) throw new Error("ไม่พบข้อมูลในฐานข้อมูล"); const data = search.hits.hits[0]; await esClient.update({ - index: process.env.ELASTICSEARCH_INDEX ?? "ehr-index", + index: DEFAULT_INDEX!, id: data._id, doc: { pathname: destination }, }); + + await minioClient.removeObject(DEFAULT_BUCKET!, current.name); }) .catch((e) => { console.error(e); - throw new Error("Failed to move."); + throw new Error("เกิดข้อผิดพลาด ไม่สามารถย้ายไฟล์ได้"); }); }), ); @@ -144,34 +180,39 @@ export class SubFolderController extends Controller { return this.setStatus(HttpStatusCode.NO_CONTENT); } + /** + * @example cabinetName "ตู้เอกสาร 1" + * @example drawerName "ลิ้นชัก 1" + * @example folderName "แฟ้ม 1" + * @example subFolderName "แฟ้มย่อย 1" + */ @Delete("/{subFolderName}") - @Tags("SubFolder") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.NO_CONTENT) + @Tags("แฟ้มย่อย") + @Security("bearerAuth", ["admin"]) + @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async deleteFolder( @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, @Path() subFolderName: string, ) { - return new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const objects: string[] = []; const stream = minioClient.listObjectsV2( - "ehr", + DEFAULT_BUCKET!, `${cabinetName}/${drawerName}/${folderName}/${subFolderName}`, true, ); stream.on("data", (v) => { - if (!(v && v.name)) return; - - objects.push(v.name); + if (v && v.name) objects.push(v.name); }); - - stream.on("close", () => minioClient.removeObjects("ehr", objects)); - stream.on("error", () => reject(new Error("Object storage error occured."))); - - resolve(this.setStatus(HttpStatusCode.NO_CONTENT)); + stream.on("close", async () => + resolve(await minioClient.removeObjects(DEFAULT_BUCKET!, objects)), + ); + stream.on("error", () => reject(new Error("เกิดข้อผิดพลาด ไม่สามารถลบไฟล์ได้"))); }); + + return this.setStatus(HttpStatusCode.NO_CONTENT); } } diff --git a/Services/server/src/controllers/subFolderFileController.ts b/Services/server/src/controllers/subFolderFileController.ts index af513d9..7d9ca33 100644 --- a/Services/server/src/controllers/subFolderFileController.ts +++ b/Services/server/src/controllers/subFolderFileController.ts @@ -1,159 +1,158 @@ import { + Body, Controller, Delete, - FormField, Get, Patch, Path, Post, Request, + Response, Route, Security, SuccessResponse, Tags, - UploadedFile, } from "tsoa"; + import esClient from "../elasticsearch"; -import minioClient from "../storage"; +import minioClient from "../minio"; + import HttpStatusCode from "../interfaces/http-status"; -import { pathExist } from "../utils/minio"; +import { StorageFile } from "../interfaces/storage-fs"; import HttpError from "../interfaces/http-error"; -import { EhrFile } from "../interfaces/ehr-fs"; + +import { copyCond, pathExist } from "../utils/minio"; + +const DEFAULT_BUCKET = process.env.MINIO_BUCKET; +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_BUCKET) throw Error("Default MinIO bucket must be specified."); +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); @Route( "/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/subfolder/{subFolderName}/file", ) export class SubFolderFileController extends Controller { - @Post("/") - @Tags("SubFolder File") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.CREATED) - public async uploadFile( - @Request() request: { user: { preferred_username: string } }, - @UploadedFile() file: Express.Multer.File, - @FormField() title: string, - @FormField() description: string, - @FormField() keyword: string, - @FormField() category: string, - @Path() cabinetName: string, - @Path() drawerName: string, - @Path() folderName: string, - @Path() subFolderName: string, - ) { - const filename = Buffer.from(file.originalname, "latin1").toString("utf-8"); - const pathname = `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/${filename}`; - - if (!(await pathExist(`${cabinetName}/${drawerName}/${folderName}/${subFolderName}`))) { - throw new HttpError( - HttpStatusCode.PRECONDITION_FAILED, - "Cabinet, drawer, folder or subfolder cannot be found.", - ); - } - - const info = await minioClient - .putObject("ehr", pathname, file.buffer, file.size, { - "Content-Type": file.mimetype, - createdAt: new Date().toISOString(), - createdBy: request.user.preferred_username, - }) - .catch((e) => console.error(e)); - - if (!info) throw new Error("Object storage error occured."); - - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - query: { - match: { - pathname: pathname, - }, - }, - }); - - const exist = search.hits.hits.find((v) => v._source?.pathname === pathname); - - const metadata: Partial = { - pathname, - fileName: filename, - fileSize: file.size, - fileType: file.mimetype, - title: title, - description: description, - category: category.split(","), - keyword: keyword.split(","), - }; - - if (!exist) { - await esClient.index({ - pipeline: "attachment", - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - document: { - data: Buffer.from(file.buffer).toString("base64"), - createdAt: new Date().toISOString(), - createdBy: request.user.preferred_username, - updatedAt: new Date().toISOString(), - updatedBy: request.user.preferred_username, - ...metadata, - }, - }); - } else { - await esClient.delete({ index: exist._index, id: exist._id }); - await esClient.index({ - pipeline: "attachment", - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - document: { - data: Buffer.from(file.buffer).toString("base64"), - createdAt: exist._source?.createdAt, - createdBy: exist._source?.createdBy, - updatedAt: new Date().toISOString(), - updatedBy: request.user.preferred_username, - ...metadata, - }, - }); - } - - return this.setStatus(HttpStatusCode.CREATED); - } - @Get("/") - @Tags("SubFolder File") - @SuccessResponse(HttpStatusCode.OK) + @Tags("ไฟล์") + @Security("bearerAuth") + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async getFile( @Path() cabinetName: string, @Path() drawerName: string, @Path() folderName: string, @Path() subFolderName: string, - ) { - const search = await esClient.search< - EhrFile & { - attachment: Record; - } - >({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', + ): Promise { + const search = await esClient.search }>({ + index: DEFAULT_INDEX!, query: { prefix: { pathname: `${cabinetName}/${drawerName}/${folderName}/${subFolderName}`, }, }, + size: 10000, }); - // Use flatMap for return type only. Filter does not change type after filter out undefined or null const records = search.hits.hits .map((v) => { - if (!v._source) return; - - const { attachment, ...rest } = v._source; - - return rest; + if (v._source) { + const { attachment, ...rest } = v._source; + return rest satisfies StorageFile; + } }) - .flatMap((v) => (v ? [v] : [])); + .filter((v: StorageFile | undefined): v is StorageFile => !!v); return records; } + @Post("/") + @Tags("ไฟล์") + @Security("bearerAuth", ["admin"]) + @Response( + HttpStatusCode.NOT_FOUND, + "ตำแหน่งที่ระบุไม่พบ กรุณาเตรียมตำแหน่งที่ต้องการก่อนดำเนินการ", + ) + @SuccessResponse(HttpStatusCode.CREATED, "สำเร็จ") + public async uploadFile( + @Request() request: { user: { preferred_username: string } }, + @Body() + body: { + file: string; + title?: string; + description?: string; + category?: string; + keyword?: string; + }, + @Path() cabinetName: string, + @Path() drawerName: string, + @Path() folderName: string, + @Path() subFolderName: string, + ) { + const basePath = `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/`; + const pathname = `${basePath}${body.file}`; + + if (!(await pathExist(DEFAULT_BUCKET!, basePath))) { + throw new HttpError( + HttpStatusCode.NOT_FOUND, + "ตำแหน่งที่ระบุไม่พบ กรุณาเตรียมตำแหน่งที่ต้องการก่อนดำเนินการ", + ); + } + + const result = await esClient + .search }>({ + index: DEFAULT_INDEX!, + query: { match: { pathname } }, + }) + .catch((e) => console.error(e)); + + // pathname is unique and should not have multiple record with same path + if (result && result.hits.hits.length > 0 && result.hits.hits[0]._source) { + await esClient + .delete({ + index: DEFAULT_INDEX!, + id: result.hits.hits[0]._id, + }) + .catch((e) => console.error(e)); + } + + const rec = result ? result.hits.hits[0]._source : false; + + const metadata: Partial = { + pathname, + fileName: body.file, + fileSize: 0, + fileType: "", + title: body.title ?? "", + description: body.description ?? "", + category: body.category?.split(",") ?? [], + keyword: body.keyword?.split(",") ?? [], + upload: false, + createdAt: new Date().toISOString(), + createdBy: rec ? rec.createdBy : "n/a", + updatedAt: new Date().toISOString(), + updatedBy: request.user.preferred_username ?? "n/a", + }; + + await esClient.index({ + index: DEFAULT_INDEX!, + document: metadata, + }); + + return { + ...body, + createdAt: metadata.createdAt, + createdBy: metadata.createdBy, + updatedAt: metadata.updatedAt, + updatedBy: metadata.updatedBy, + upload: await minioClient.presignedPutObject(DEFAULT_BUCKET!, pathname), + }; + } + @Patch("/{fileName}") - @Tags("SubFolder File") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.OK) + @Tags("ไฟล์") + @Security("bearerAuth", ["admin"]) + @Response(HttpStatusCode.NOT_FOUND, "ไม่พบตำแหน่งที่ต้องการสร้างแฟ้ม") + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async updateFile( @Request() request: { user: { preferred_username: string } }, @Path() cabinetName: string, @@ -161,92 +160,98 @@ export class SubFolderFileController extends Controller { @Path() folderName: string, @Path() subFolderName: string, @Path() fileName: string, - @UploadedFile() file?: Express.Multer.File, - @FormField() title?: string, - @FormField() description?: string, - @FormField() keyword?: string, - @FormField() category?: string, + @Body() + body: { + file?: string; + title?: string; + description?: string; + category?: string; + keyword?: string; + }, ) { - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - query: { - match: { - pathname: `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/${fileName}`, - }, - }, - }); + const basePath = `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/`; + const pathname = `${basePath}${fileName}`; - if (search && search.hits.hits.length === 0) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "Not found"); + if ( + !Boolean( + await minioClient.statObject(DEFAULT_BUCKET!, `${pathname}`).catch((e) => { + if (e.code === "NotFound") return false; + throw new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"); + }), + ) + ) { + throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบไฟล์"); } - const data = search.hits.hits[0]; + // assume user will probably replace file by re-upload but maybe just rename + if (body.file) { + const destination = `${basePath}${body.file}`; + const source = `/${DEFAULT_BUCKET}/${basePath}${fileName}`; + const copy = await minioClient.copyObject(DEFAULT_BUCKET!, destination, source, copyCond); - if (!file) { - const esResult = await esClient - .update({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - id: data._id, - doc: { - title, - description, - keyword: keyword?.split(","), - category: category?.split(","), - updatedAt: new Date().toISOString(), - updatedBy: request.user.preferred_username, - }, - }) - .catch((e) => console.error(e)); + if (copy) { + const search = await esClient + .search }>({ + index: DEFAULT_INDEX!, + query: { match: { pathname } }, + }) + .catch((e) => console.error(e)); - if (!esResult) throw new Error("An error occured, cannot perform this action."); + if (search && search.hits.hits.length > 0 && search.hits.hits[0]._source) { + const { _index: index, _id: id } = search.hits.hits[0]; + await esClient + .update({ + index, + id, + doc: { + pathname: destination, + updatedAt: new Date().toISOString(), + updatedBy: request.user.preferred_username ?? "n/a", + }, + }) + .then(() => minioClient.removeObject(DEFAULT_BUCKET!, pathname)); + } else { + await minioClient.removeObject(DEFAULT_BUCKET!, pathname); + } + } } else { - const filename = Buffer.from(file.originalname, "latin1").toString("utf-8"); - const pathname = `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/${filename}`; - - await minioClient.removeObject( - "ehr", - `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/${fileName}`, - ); - - const info = await minioClient - .putObject("ehr", pathname, file.buffer, file.size, { - "Content-Type": file.mimetype, - createdAt: new Date().toISOString(), - createdBy: request.user.preferred_username, + const search = await esClient + .search }>({ + index: DEFAULT_INDEX!, + query: { match: { pathname } }, }) .catch((e) => console.error(e)); - if (!info) throw new Error("Object storage error occured."); - - await esClient.delete({ index: data._index, id: data._id }); - await esClient.index({ - pipeline: "attachment", - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - document: { - data: Buffer.from(file.buffer).toString("base64"), - pathname, - fileName: filename, - fileSize: file.size, - fileType: file.mimetype, - title: title, - description: description, - category: category?.split(","), - keyword: keyword?.split(","), - createdAt: data._source?.createdAt, - createdBy: data._source?.createdBy, - updatedAt: new Date().toISOString(), - updatedBy: request.user.preferred_username, - }, - }); + if (search && search.hits.hits.length > 0 && search.hits.hits[0]._source) { + const { _index: index, _id: id } = search.hits.hits[0]; + await esClient.update({ + index, + id, + doc: { + ...body, + keyword: body.keyword?.split(","), + category: body.category?.split(","), + updatedAt: new Date().toISOString(), + updatedBy: request.user.preferred_username ?? "n/a", + }, + }); + } } - return this.setStatus(HttpStatusCode.NO_CONTENT); + return body.file + ? { + upload: await minioClient.presignedPutObject( + DEFAULT_BUCKET!, + `${basePath}${body.file ?? fileName}`, + ), + } + : this.setStatus(HttpStatusCode.NO_CONTENT); } @Delete("/{fileName}") - @Tags("SubFolder File") - @Security("bearerAuth") - @SuccessResponse(HttpStatusCode.OK) + @Tags("ไฟล์") + @Security("bearerAuth", ["admin"]) + @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async deleteFile( @Path() cabinetName: string, @Path() drawerName: string, @@ -254,42 +259,16 @@ export class SubFolderFileController extends Controller { @Path() subFolderName: string, @Path() fileName: string, ) { - const search = await esClient.search< - EhrFile & { - attachment: Record; - } - >({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - query: { - match: { - pathname: `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/${fileName}`, - }, - }, - }); - - if (search && search.hits.hits.length === 0) { - throw new HttpError(HttpStatusCode.NOT_FOUND, "Not found"); - } - - const esResult = await esClient - .delete({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', - id: search.hits.hits[0]._id, - }) - .catch((e) => console.error(e)); - - if (!esResult) throw new Error("An error occured, cannot perform this action."); - await minioClient.removeObject( - "ehr", - `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/${fileName}`, + DEFAULT_BUCKET!, + `${cabinetName}/${drawerName}/${folderName}/${fileName}/${subFolderName}/`, ); - return this.setStatus(HttpStatusCode.NO_CONTENT); } @Get("/{fileName}") - @Tags("File") + @Tags("ดาวน์โหลด") + @Security("bearerAuth") @SuccessResponse(HttpStatusCode.OK) public async downloadFile( @Path() cabinetName: string, @@ -298,8 +277,8 @@ export class SubFolderFileController extends Controller { @Path() subFolderName: string, @Path() fileName: string, ) { - const search = await esClient.search }>({ - index: process.env.ELASTICSEARCH_INDEX ?? 'ehr-index', + const search = await esClient.search }>({ + index: DEFAULT_INDEX!, query: { match: { pathname: `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/${fileName}`, @@ -311,19 +290,13 @@ export class SubFolderFileController extends Controller { throw new HttpError(HttpStatusCode.NOT_FOUND, "Not found"); } - const data = search.hits.hits[0]._source; - - if (!data) { - throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "Found data but no info."); - } - - const { attachment, ...rest } = data; + const { attachment, ...rest } = search.hits.hits[0]._source!; return { ...rest, download: await minioClient.presignedGetObject( - "ehr", - `${cabinetName}/${drawerName}/${folderName}/${subFolderName}/${fileName}`, + DEFAULT_BUCKET!, + `${cabinetName}/${drawerName}/${folderName}/${fileName}`, ), }; } diff --git a/Services/server/src/interfaces/ehr-fs.ts b/Services/server/src/interfaces/storage-fs.ts similarity index 83% rename from Services/server/src/interfaces/ehr-fs.ts rename to Services/server/src/interfaces/storage-fs.ts index 919cbcc..58737fd 100644 --- a/Services/server/src/interfaces/ehr-fs.ts +++ b/Services/server/src/interfaces/storage-fs.ts @@ -1,4 +1,4 @@ -export interface EhrFolder { +export interface StorageFolder { /** * @prop Full path to this folder. It is used as key as there are no files or directories at the same location. */ @@ -12,7 +12,7 @@ export interface EhrFolder { createdBy: string | Date; } -export interface EhrFile { +export interface StorageFile { /** * @prop Full path to this folder. It is used as key as there are no files or directories at the same location. */ @@ -27,6 +27,11 @@ export interface EhrFile { category: string[]; keyword: string[]; + /** + * @private For internal use only. + */ + upload: boolean; + updatedAt: string | Date; updatedBy: string; createdAt: string | Date; diff --git a/Services/server/src/storage/index.ts b/Services/server/src/minio/index.ts similarity index 100% rename from Services/server/src/storage/index.ts rename to Services/server/src/minio/index.ts diff --git a/Services/server/src/rabbitmq/handler.ts b/Services/server/src/rabbitmq/handler.ts new file mode 100644 index 0000000..82c51ae --- /dev/null +++ b/Services/server/src/rabbitmq/handler.ts @@ -0,0 +1,149 @@ +import { StorageFile } from "../interfaces/storage-fs"; +import esClient from "../elasticsearch"; +import minioClient from "../minio"; + +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); + +// for failed queue that will come later +const cachedBuffer: Record = {}; +const cachedMetadata: Record = {}; + +export async function handler(key: string, event: string): Promise { + console.info(`[AMQ] Messages received - key: ${key}, event: ${event}`); + + const [bucket, ...fragment] = key.split("/"); + const pathname = fragment.join("/"); + + if (event === "s3:ObjectRemoved:Delete") { + return await ensureDelete(pathname); + } + + if (!cachedBuffer[key]) { + const stream = await minioClient.getObject(bucket, pathname); + const buffer = Buffer.concat(await stream.toArray()); + cachedBuffer[key] = buffer; + } + + if (!cachedMetadata[key]) { + const stat = await minioClient.statObject(bucket, pathname); + cachedMetadata[key] = { size: stat.size, type: stat.metaData["content-type"] }; + } + + const rec = await popInfo(pathname); + + const result = rec + ? await handleFoundRecord(rec, cachedBuffer[key], cachedMetadata[key]) + : await handleNotFoundRecord(pathname, cachedBuffer[key], cachedMetadata[key]); + + if (result) { + delete cachedBuffer[key]; + delete cachedMetadata[key]; + } + + return result; +} + +// Get info and delete it from ElasticSearch to re-index +async function popInfo(pathname: string) { + const result = await esClient + .search }>({ + index: DEFAULT_INDEX!, + query: { match: { pathname } }, + }) + .catch((e) => console.error(e)); + + // pathname is unique and should not have multiple record with same path + if (result && result.hits.hits.length > 0 && result.hits.hits[0]._source) { + await esClient + .delete({ + index: DEFAULT_INDEX!, + id: result.hits.hits[0]._id, + }) + .catch((e) => console.error(e)); + + return result.hits.hits[0]._source; + } + + if (result && result.hits.hits.length === 0) console.info("Index Not Found"); + + return false; +} + +/** + * If there is data in database then delete it + */ +async function ensureDelete(pathname: string) { + await esClient + .deleteByQuery({ + index: DEFAULT_INDEX!, + query: { match: { pathname } }, + conflicts: "proceed", + }) + .catch((e) => console.error(e)); + return true; +} + +/** + * Handle when record in elasticsearch cannot be found. + * This will insert empty metadata. + */ +async function handleNotFoundRecord( + pathname: string, + buffer: Buffer, + stat: { size: number; type: string }, +) { + const filename = pathname.split("/").at(-1); + const base64 = Buffer.from(buffer).toString("base64"); + + const metadata = { + pathname, + fileName: filename ?? "n/a", // should not possible to fallback, but just in case. + fileSize: stat.size, + fileType: stat.type, + title: "", + description: "", + category: [], + keyword: [], + upload: true, + createdAt: new Date().toISOString(), + createdBy: "n/a", + updatedAt: new Date().toISOString(), + updatedBy: "n/a", + } satisfies Partial; + + const result = await esClient + .index({ + pipeline: "attachment", + index: DEFAULT_INDEX!, + document: { data: base64, ...metadata }, + }) + .catch((e) => console.error(e)); + + if (result) return true; + + return false; +} + +async function handleFoundRecord( + metadata: StorageFile, + buffer: Buffer, + stat: { size: number; type: string }, +) { + metadata.fileSize = stat.size; + metadata.fileType = stat.type; + metadata.upload = true; + + const result = await esClient + .index({ + pipeline: "attachment", + index: DEFAULT_INDEX!, + document: { data: Buffer.from(buffer).toString("base64"), ...metadata }, + }) + .catch((e) => console.error(e)); + + if (result) return true; + + return false; +} diff --git a/Services/server/src/rabbitmq/index.ts b/Services/server/src/rabbitmq/index.ts new file mode 100644 index 0000000..e900210 --- /dev/null +++ b/Services/server/src/rabbitmq/index.ts @@ -0,0 +1,40 @@ +import amqp from "amqplib"; + +export async function init(cb: (key: string, event: string) => boolean | Promise) { + if (!process.env.AMQ_URL || !process.env.AMQ_QUEUE) return; + + const { AMQ_URL: url, AMQ_QUEUE: queue } = process.env; + + const connection = await amqp.connect(url); + + const channel = await connection.createChannel(); + + channel.assertQueue(queue, { durable: true }); + channel.prefetch(1); + + console.log("[AMQ] Listening for message..."); + + channel.consume( + queue, + async (msg) => { + if (!msg) return; + + const parsed: Record = JSON.parse(msg.content.toString()); + + if (typeof parsed.Key !== "string" || parsed.Key.includes(".keep")) return channel.ack(msg); + if (typeof parsed.EventName !== "string" || parsed.EventName.includes("Copy")) { + return channel.ack(msg); + } + + const key = parsed.Key; + const event = parsed.EventName; + + if (await cb(key, event)) return channel.ack(msg); + + return await new Promise((resolve) => setTimeout(() => resolve(channel.nack(msg)), 3000)); + }, + { noAck: false }, + ); +} + +export default { init }; diff --git a/Services/server/src/routes.ts b/Services/server/src/routes.ts index 2ee984d..845fb8b 100644 --- a/Services/server/src/routes.ts +++ b/Services/server/src/routes.ts @@ -13,6 +13,8 @@ import { FolderController } from './controllers/folderController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { SearchController } from './controllers/searchController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +import { StorageController } from './controllers/storageController'; +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { SubFolderController } from './controllers/subFolderController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { SubFolderFileController } from './controllers/subFolderFileController'; @@ -20,13 +22,11 @@ import { expressAuthentication } from './utils/auth'; // @ts-ignore - no great way to install types from subpackage const promiseAny = require('promise.any'); import type { RequestHandler, Router } from 'express'; -const multer = require('multer'); -const upload = multer(); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const models: TsoaRoute.Models = { - "EhrFolder": { + "StorageFolder": { "dataType": "refObject", "properties": { "pathname": {"dataType":"string","required":true}, @@ -37,7 +37,7 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "EhrFile": { + "StorageFile": { "dataType": "refObject", "properties": { "pathname": {"dataType":"string","required":true}, @@ -48,6 +48,7 @@ const models: TsoaRoute.Models = { "description": {"dataType":"string","required":true}, "category": {"dataType":"array","array":{"dataType":"string"},"required":true}, "keyword": {"dataType":"array","array":{"dataType":"string"},"required":true}, + "upload": {"dataType":"boolean","required":true}, "updatedAt": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"datetime"}],"required":true}, "updatedBy": {"dataType":"string","required":true}, "createdAt": {"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"datetime"}],"required":true}, @@ -76,6 +77,7 @@ export function RegisterRoutes(app: Router) { // Please look into the "controllerPathGlobs" config option described in the readme: https://github.com/lukeautry/tsoa // ########################################################################################################### app.get('/cabinet', + authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(CabinetController)), ...(fetchMiddlewares(CabinetController.prototype.listCabinet)), @@ -100,7 +102,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.post('/cabinet', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(CabinetController)), ...(fetchMiddlewares(CabinetController.prototype.createCabinet)), @@ -127,7 +129,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.put('/cabinet/:cabinetName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(CabinetController)), ...(fetchMiddlewares(CabinetController.prototype.editCabinet)), @@ -154,7 +156,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.delete('/cabinet/:cabinetName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(CabinetController)), ...(fetchMiddlewares(CabinetController.prototype.deleteCabinet)), @@ -180,6 +182,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/cabinet/:cabinetName/drawer', + authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(DrawerController)), ...(fetchMiddlewares(DrawerController.prototype.listDrawer)), @@ -205,7 +208,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.post('/cabinet/:cabinetName/drawer', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(DrawerController)), ...(fetchMiddlewares(DrawerController.prototype.createDrawer)), @@ -233,7 +236,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.put('/cabinet/:cabinetName/drawer/:drawerName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(DrawerController)), ...(fetchMiddlewares(DrawerController.prototype.editDrawer)), @@ -261,7 +264,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.delete('/cabinet/:cabinetName/drawer/:drawerName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(DrawerController)), ...(fetchMiddlewares(DrawerController.prototype.deleteDrawer)), @@ -287,42 +290,8 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - app.post('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/file', - authenticateMiddleware([{"bearerAuth":[]}]), - upload.single('file'), - ...(fetchMiddlewares(FileController)), - ...(fetchMiddlewares(FileController.prototype.uploadFile)), - - function FileController_uploadFile(request: any, response: any, next: any) { - const args = { - request: {"in":"request","name":"request","required":true,"dataType":"object"}, - file: {"in":"formData","name":"file","required":true,"dataType":"file"}, - title: {"in":"formData","name":"title","required":true,"dataType":"string"}, - description: {"in":"formData","name":"description","required":true,"dataType":"string"}, - keyword: {"in":"formData","name":"keyword","required":true,"dataType":"string"}, - category: {"in":"formData","name":"category","required":true,"dataType":"string"}, - cabinetName: {"in":"path","name":"cabinetName","required":true,"dataType":"string"}, - drawerName: {"in":"path","name":"drawerName","required":true,"dataType":"string"}, - folderName: {"in":"path","name":"folderName","required":true,"dataType":"string"}, - }; - - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - - let validatedArgs: any[] = []; - try { - validatedArgs = getValidatedArgs(args, request, response); - - const controller = new FileController(); - - - const promise = controller.uploadFile.apply(controller, validatedArgs as any); - promiseHandler(controller, promise, response, 201, next); - } catch (err) { - return next(err); - } - }); - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/file', + authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(FileController)), ...(fetchMiddlewares(FileController.prototype.getFile)), @@ -349,9 +318,38 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/file', + authenticateMiddleware([{"bearerAuth":["admin"]}]), + ...(fetchMiddlewares(FileController)), + ...(fetchMiddlewares(FileController.prototype.uploadFile)), + + function FileController_uploadFile(request: any, response: any, next: any) { + const args = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + body: {"in":"body","name":"body","required":true,"dataType":"nestedObjectLiteral","nestedProperties":{"keyword":{"dataType":"string"},"category":{"dataType":"string"},"description":{"dataType":"string"},"title":{"dataType":"string"},"file":{"dataType":"string","required":true}}}, + cabinetName: {"in":"path","name":"cabinetName","required":true,"dataType":"string"}, + drawerName: {"in":"path","name":"drawerName","required":true,"dataType":"string"}, + folderName: {"in":"path","name":"folderName","required":true,"dataType":"string"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new FileController(); + + + const promise = controller.uploadFile.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 201, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.patch('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/file/:fileName', - authenticateMiddleware([{"bearerAuth":[]}]), - upload.single('file'), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(FileController)), ...(fetchMiddlewares(FileController.prototype.updateFile)), @@ -362,11 +360,7 @@ export function RegisterRoutes(app: Router) { drawerName: {"in":"path","name":"drawerName","required":true,"dataType":"string"}, folderName: {"in":"path","name":"folderName","required":true,"dataType":"string"}, fileName: {"in":"path","name":"fileName","required":true,"dataType":"string"}, - file: {"in":"formData","name":"file","dataType":"file"}, - title: {"in":"formData","name":"title","dataType":"string"}, - description: {"in":"formData","name":"description","dataType":"string"}, - keyword: {"in":"formData","name":"keyword","dataType":"string"}, - category: {"in":"formData","name":"category","dataType":"string"}, + body: {"in":"body","name":"body","required":true,"dataType":"nestedObjectLiteral","nestedProperties":{"keyword":{"dataType":"string"},"category":{"dataType":"string"},"description":{"dataType":"string"},"title":{"dataType":"string"},"file":{"dataType":"string"}}}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -386,7 +380,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.delete('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/file/:fileName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(FileController)), ...(fetchMiddlewares(FileController.prototype.deleteFile)), @@ -415,6 +409,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/file/:fileName', + authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(FileController)), ...(fetchMiddlewares(FileController.prototype.downloadFile)), @@ -443,6 +438,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/cabinet/:cabinetName/drawer/:drawerName/folder', + authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(FolderController)), ...(fetchMiddlewares(FolderController.prototype.listFolder)), @@ -469,7 +465,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.post('/cabinet/:cabinetName/drawer/:drawerName/folder', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(FolderController)), ...(fetchMiddlewares(FolderController.prototype.createFolder)), @@ -498,7 +494,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.put('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(FolderController)), ...(fetchMiddlewares(FolderController.prototype.editFolder)), @@ -527,7 +523,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.delete('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(FolderController)), ...(fetchMiddlewares(FolderController.prototype.deleteFolder)), @@ -555,6 +551,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.post('/search', + authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(SearchController)), ...(fetchMiddlewares(SearchController.prototype.searchFile)), @@ -580,6 +577,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder', + authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(SubFolderController)), ...(fetchMiddlewares(SubFolderController.prototype.listFolder)), @@ -607,7 +605,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.post('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(SubFolderController)), ...(fetchMiddlewares(SubFolderController.prototype.createFolder)), @@ -637,7 +635,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.put('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(SubFolderController)), ...(fetchMiddlewares(SubFolderController.prototype.editFolder)), @@ -667,7 +665,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.delete('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(SubFolderController)), ...(fetchMiddlewares(SubFolderController.prototype.deleteFolder)), @@ -695,43 +693,8 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - app.post('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName/file', - authenticateMiddleware([{"bearerAuth":[]}]), - upload.single('file'), - ...(fetchMiddlewares(SubFolderFileController)), - ...(fetchMiddlewares(SubFolderFileController.prototype.uploadFile)), - - function SubFolderFileController_uploadFile(request: any, response: any, next: any) { - const args = { - request: {"in":"request","name":"request","required":true,"dataType":"object"}, - file: {"in":"formData","name":"file","required":true,"dataType":"file"}, - title: {"in":"formData","name":"title","required":true,"dataType":"string"}, - description: {"in":"formData","name":"description","required":true,"dataType":"string"}, - keyword: {"in":"formData","name":"keyword","required":true,"dataType":"string"}, - category: {"in":"formData","name":"category","required":true,"dataType":"string"}, - cabinetName: {"in":"path","name":"cabinetName","required":true,"dataType":"string"}, - drawerName: {"in":"path","name":"drawerName","required":true,"dataType":"string"}, - folderName: {"in":"path","name":"folderName","required":true,"dataType":"string"}, - subFolderName: {"in":"path","name":"subFolderName","required":true,"dataType":"string"}, - }; - - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - - let validatedArgs: any[] = []; - try { - validatedArgs = getValidatedArgs(args, request, response); - - const controller = new SubFolderFileController(); - - - const promise = controller.uploadFile.apply(controller, validatedArgs as any); - promiseHandler(controller, promise, response, 201, next); - } catch (err) { - return next(err); - } - }); - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName/file', + authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(SubFolderFileController)), ...(fetchMiddlewares(SubFolderFileController.prototype.getFile)), @@ -759,9 +722,39 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName/file', + authenticateMiddleware([{"bearerAuth":["admin"]}]), + ...(fetchMiddlewares(SubFolderFileController)), + ...(fetchMiddlewares(SubFolderFileController.prototype.uploadFile)), + + function SubFolderFileController_uploadFile(request: any, response: any, next: any) { + const args = { + request: {"in":"request","name":"request","required":true,"dataType":"object"}, + body: {"in":"body","name":"body","required":true,"dataType":"nestedObjectLiteral","nestedProperties":{"keyword":{"dataType":"string"},"category":{"dataType":"string"},"description":{"dataType":"string"},"title":{"dataType":"string"},"file":{"dataType":"string","required":true}}}, + cabinetName: {"in":"path","name":"cabinetName","required":true,"dataType":"string"}, + drawerName: {"in":"path","name":"drawerName","required":true,"dataType":"string"}, + folderName: {"in":"path","name":"folderName","required":true,"dataType":"string"}, + subFolderName: {"in":"path","name":"subFolderName","required":true,"dataType":"string"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new SubFolderFileController(); + + + const promise = controller.uploadFile.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 201, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.patch('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName/file/:fileName', - authenticateMiddleware([{"bearerAuth":[]}]), - upload.single('file'), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(SubFolderFileController)), ...(fetchMiddlewares(SubFolderFileController.prototype.updateFile)), @@ -773,11 +766,7 @@ export function RegisterRoutes(app: Router) { folderName: {"in":"path","name":"folderName","required":true,"dataType":"string"}, subFolderName: {"in":"path","name":"subFolderName","required":true,"dataType":"string"}, fileName: {"in":"path","name":"fileName","required":true,"dataType":"string"}, - file: {"in":"formData","name":"file","dataType":"file"}, - title: {"in":"formData","name":"title","dataType":"string"}, - description: {"in":"formData","name":"description","dataType":"string"}, - keyword: {"in":"formData","name":"keyword","dataType":"string"}, - category: {"in":"formData","name":"category","dataType":"string"}, + body: {"in":"body","name":"body","required":true,"dataType":"nestedObjectLiteral","nestedProperties":{"keyword":{"dataType":"string"},"category":{"dataType":"string"},"description":{"dataType":"string"},"title":{"dataType":"string"},"file":{"dataType":"string"}}}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -797,7 +786,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.delete('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName/file/:fileName', - authenticateMiddleware([{"bearerAuth":[]}]), + authenticateMiddleware([{"bearerAuth":["admin"]}]), ...(fetchMiddlewares(SubFolderFileController)), ...(fetchMiddlewares(SubFolderFileController.prototype.deleteFile)), @@ -827,6 +816,7 @@ export function RegisterRoutes(app: Router) { }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder/:subFolderName/file/:fileName', + authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(SubFolderFileController)), ...(fetchMiddlewares(SubFolderFileController.prototype.downloadFile)), diff --git a/Services/server/src/swagger.json b/Services/server/src/swagger.json index d9bde06..ebdf260 100644 --- a/Services/server/src/swagger.json +++ b/Services/server/src/swagger.json @@ -6,7 +6,7 @@ "requestBodies": {}, "responses": {}, "schemas": { - "EhrFolder": { + "StorageFolder": { "properties": { "pathname": { "type": "string" @@ -46,7 +46,7 @@ "type": "object", "additionalProperties": false }, - "EhrFile": { + "StorageFile": { "properties": { "pathname": { "type": "string" @@ -79,6 +79,9 @@ }, "type": "array" }, + "upload": { + "type": "boolean" + }, "updatedAt": { "anyOf": [ { @@ -117,6 +120,7 @@ "description", "category", "keyword", + "upload", "updatedAt", "updatedBy", "createdAt", @@ -178,9 +182,9 @@ } }, "info": { - "title": "BMA EHR - Test Service API", - "version": "0.0.1", - "description": "Best practice for initialize express project", + "title": "Enterprise Document Management(EDM) - API", + "version": "0.0.2", + "description": "Open API Specfication for Enterprise Document Management ", "license": { "name": "by Frappet", "url": "https://frappet.com" @@ -193,38 +197,68 @@ "operationId": "ListCabinet", "responses": { "200": { - "description": "", + "description": "สำเร็จ", "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/EhrFolder" + "$ref": "#/components/schemas/StorageFolder" }, "type": "array" + }, + "examples": { + "Example 1": { + "value": [ + { + "path": "ตู้เอกสาร 1/", + "name": "ตู้เอกสาร 1", + "createdAt": "2021-07-20T12:33:13.018Z", + "createdBy": "admin" + }, + { + "path": "ตู้เอกสาร 2/", + "name": "ตู้เอกสาร 2", + "createdAt": "2022-01-23T16:05:02.114Z", + "createdBy": "admin" + } + ] + } } } } + }, + "500": { + "description": "เกิดข้อผิดพลาด ไม่สามารถแสดงรายการตู้เอกสารได้ กรุณาลองใหม่ในภายหลัง" } }, "tags": [ - "Cabinet" + "ตู้เอกสาร" + ], + "security": [ + { + "bearerAuth": [] + } ], - "security": [], "parameters": [] }, "post": { "operationId": "CreateCabinet", "responses": { "201": { - "description": "" + "description": "สำเร็จ" + }, + "500": { + "description": "เกิดข้อผิดพลาดกับระบบจัดการไฟล์" } }, "tags": [ - "Cabinet" + "ตู้เอกสาร" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [], @@ -235,7 +269,8 @@ "schema": { "properties": { "name": { - "type": "string" + "type": "string", + "example": "ตู้เอกสาร 1" } }, "required": [ @@ -253,15 +288,20 @@ "operationId": "EditCabinet", "responses": { "204": { - "description": "Success" + "description": "สำเร็จ" + }, + "500": { + "description": "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้" } }, "tags": [ - "Cabinet" + "ตู้เอกสาร" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -271,7 +311,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ตู้เอกสาร 1" } ], "requestBody": { @@ -281,7 +322,8 @@ "schema": { "properties": { "name": { - "type": "string" + "type": "string", + "example": "ตู้เอกสารใหม่" } }, "required": [ @@ -297,20 +339,20 @@ "operationId": "DeleteCabinet", "responses": { "204": { - "description": "", - "content": { - "application/json": { - "schema": {} - } - } + "description": "สำเร็จ" + }, + "500": { + "description": "เกิดข้อผิดพลาด ไม่สามารถลบไฟล์ได้" } }, "tags": [ - "Cabinet" + "ตู้เอกสาร" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -320,7 +362,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ตู้เอกสาร 1" } ] } @@ -330,43 +373,42 @@ "operationId": "ListDrawer", "responses": { "200": { - "description": "", + "description": "สำเร็จ", "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/EhrFolder" + "$ref": "#/components/schemas/StorageFolder" }, "type": "array" + }, + "examples": { + "Example 1": { + "value": [ + { + "path": "ตู้เอกสาร 1/ลิ้นชัก 1/", + "name": "ลิ้นชัก 1", + "createdAt": "2021-07-20T12:33:13.018Z", + "createdBy": "admin" + }, + { + "path": "ตู้เอกสาร 1/ลิ้นชัก 2/", + "name": "ลิ้นชัก 2", + "createdAt": "2022-01-23T16:05:02.114Z", + "createdBy": "admin" + } + ] + } } } } + }, + "500": { + "description": "เกิดข้อผิดพลาด ไม่สามารถแสดงรายการลิ้นชักได้ กรุณาลองใหม่ในภายหลัง" } }, "tags": [ - "Drawer" - ], - "security": [], - "parameters": [ - { - "in": "path", - "name": "cabinetName", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "post": { - "operationId": "CreateDrawer", - "responses": { - "201": { - "description": "" - } - }, - "tags": [ - "Drawer" + "ลิ้นชัก" ], "security": [ { @@ -380,7 +422,43 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ตู้เอกสาร 1" + } + ] + }, + "post": { + "operationId": "CreateDrawer", + "responses": { + "201": { + "description": "สำเร็จ" + }, + "404": { + "description": "ไม่พบลิ้นชัก" + }, + "500": { + "description": "เกิดข้อผิดพลาดกับระบบจัดการไฟล์" + } + }, + "tags": [ + "ลิ้นชัก" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "cabinetName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ตู้เอกสาร 1" } ], "requestBody": { @@ -390,7 +468,8 @@ "schema": { "properties": { "name": { - "type": "string" + "type": "string", + "example": "ลิ้นชัก 1" } }, "required": [ @@ -408,15 +487,20 @@ "operationId": "EditDrawer", "responses": { "204": { - "description": "" + "description": "สำเร็จ" + }, + "500": { + "description": "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้" } }, "tags": [ - "Drawer" + "ลิ้นชัก" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -426,7 +510,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ตู้เอกสาร 1" }, { "in": "path", @@ -434,7 +519,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ลิ้นชัก 1" } ], "requestBody": { @@ -444,7 +530,8 @@ "schema": { "properties": { "name": { - "type": "string" + "type": "string", + "example": "ลิ้นชักใหม่" } }, "required": [ @@ -460,16 +547,61 @@ "operationId": "DeleteDrawer", "responses": { "204": { - "description": "", + "description": "สำเร็จ" + } + }, + "tags": [ + "ลิ้นชัก" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "cabinetName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ตู้เอกสาร 1" + }, + { + "in": "path", + "name": "drawerName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ลิ้นชัก 1" + } + ] + } + }, + "/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/file": { + "get": { + "operationId": "GetFile", + "responses": { + "200": { + "description": "สำเร็จ", "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "$ref": "#/components/schemas/StorageFile" + }, + "type": "array" + } } } } }, "tags": [ - "Drawer" + "ไฟล์" ], "security": [ { @@ -492,24 +624,98 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "folderName", + "required": true, + "schema": { + "type": "string" + } } ] - } - }, - "/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/file": { + }, "post": { "operationId": "UploadFile", "responses": { "201": { - "description": "" + "description": "สำเร็จ", + "content": { + "application/json": { + "schema": { + "properties": { + "keyword": { + "type": "string" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "file": { + "type": "string" + }, + "upload": { + "type": "string" + }, + "updatedBy": { + "type": "string" + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "createdBy": { + "type": "string" + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "format": "date-time" + } + ] + } + }, + "required": [ + "file", + "upload", + "updatedBy", + "updatedAt", + "createdBy", + "createdAt" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "ตำแหน่งที่ระบุไม่พบ กรุณาเตรียมตำแหน่งที่ต้องการก่อนดำเนินการ" } }, "tags": [ - "File" + "ไฟล์" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -541,86 +747,33 @@ "requestBody": { "required": true, "content": { - "multipart/form-data": { + "application/json": { "schema": { - "type": "object", "properties": { - "file": { - "type": "string", - "format": "binary" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, "keyword": { "type": "string" }, "category": { "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "file": { + "type": "string" } }, "required": [ - "file", - "title", - "description", - "keyword", - "category" - ] + "file" + ], + "type": "object" } } } } - }, - "get": { - "operationId": "GetFile", - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/EhrFile" - }, - "type": "array" - } - } - } - } - }, - "tags": [ - "File" - ], - "security": [], - "parameters": [ - { - "in": "path", - "name": "cabinetName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "drawerName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "folderName", - "required": true, - "schema": { - "type": "string" - } - } - ] } }, "/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/file/{fileName}": { @@ -628,15 +781,43 @@ "operationId": "UpdateFile", "responses": { "200": { - "description": "" + "description": "สำเร็จ", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {}, + { + "properties": { + "upload": { + "type": "string" + } + }, + "required": [ + "upload" + ], + "type": "object" + } + ] + } + } + } + }, + "204": { + "description": "สำเร็จ" + }, + "404": { + "description": "ไม่พบตำแหน่งที่ต้องการสร้างแฟ้ม" } }, "tags": [ - "File" + "ไฟล์" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -674,29 +855,28 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { - "multipart/form-data": { + "application/json": { "schema": { - "type": "object", "properties": { - "file": { - "type": "string", - "format": "binary" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, "keyword": { "type": "string" }, "category": { "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "file": { + "type": "string" } - } + }, + "type": "object" } } } @@ -706,15 +886,17 @@ "operationId": "DeleteFile", "responses": { "200": { - "description": "" + "description": "สำเร็จ" } }, "tags": [ - "File" + "ไฟล์" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -756,7 +938,7 @@ "operationId": "DownloadFile", "responses": { "200": { - "description": "", + "description": "สำเร็จ", "content": { "application/json": { "schema": { @@ -789,6 +971,9 @@ } ] }, + "upload": { + "type": "boolean" + }, "keyword": { "items": { "type": "string" @@ -829,6 +1014,7 @@ "createdAt", "updatedBy", "updatedAt", + "upload", "keyword", "category", "description", @@ -846,9 +1032,13 @@ } }, "tags": [ - "File" + "ดาวน์โหลด" + ], + "security": [ + { + "bearerAuth": [] + } ], - "security": [], "parameters": [ { "in": "path", @@ -890,51 +1080,42 @@ "operationId": "ListFolder", "responses": { "200": { - "description": "", + "description": "สำเร็จ", "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/EhrFolder" + "$ref": "#/components/schemas/StorageFolder" }, "type": "array" + }, + "examples": { + "Example 1": { + "value": [ + { + "path": "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 1", + "name": "แฟ้ม 1", + "createdAt": "2021-07-20T12:33:13.018Z", + "createdBy": "admin" + }, + { + "path": "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 2", + "name": "แฟ้ม 2", + "createdAt": "2022-01-23T16:05:02.114Z", + "createdBy": "admin" + } + ] + } } } } - } - }, - "tags": [ - "Folder" - ], - "security": [], - "parameters": [ - { - "in": "path", - "name": "cabinetName", - "required": true, - "schema": { - "type": "string" - } }, - { - "in": "path", - "name": "drawerName", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "post": { - "operationId": "CreateFolder", - "responses": { - "201": { - "description": "" + "500": { + "description": "เกิดข้อผิดพลาด ไม่สามารถแสดงรายการแฟ้มได้ กรุณาลองใหม่ในภายหลัง" } }, "tags": [ - "Folder" + "แฟ้ม" ], "security": [ { @@ -948,7 +1129,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ตู้เอกสาร 1" }, { "in": "path", @@ -956,7 +1138,52 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ลิ้นชัก 1" + } + ] + }, + "post": { + "operationId": "CreateFolder", + "responses": { + "201": { + "description": "สำเร็จ" + }, + "404": { + "description": "ไม่พบตำแหน่งที่ต้องการสร้างแฟ้ม" + }, + "500": { + "description": "เกิดข้อผิดพลาดกับระบบจัดการไฟล์" + } + }, + "tags": [ + "แฟ้ม" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "cabinetName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ตู้เอกสาร 1" + }, + { + "in": "path", + "name": "drawerName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ลิ้นชัก 1" } ], "requestBody": { @@ -966,7 +1193,8 @@ "schema": { "properties": { "name": { - "type": "string" + "type": "string", + "example": "แฟ้ม 1" } }, "required": [ @@ -984,15 +1212,20 @@ "operationId": "EditFolder", "responses": { "204": { - "description": "" + "description": "สำเร็จ" + }, + "500": { + "description": "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้" } }, "tags": [ - "Folder" + "แฟ้ม" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -1002,7 +1235,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ตู้เอกสาร 1" }, { "in": "path", @@ -1010,7 +1244,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ลิ้นชัก 1" }, { "in": "path", @@ -1018,7 +1253,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "แฟ้ม 1" } ], "requestBody": { @@ -1028,7 +1264,8 @@ "schema": { "properties": { "name": { - "type": "string" + "type": "string", + "example": "แฟ้มใหม่" } }, "required": [ @@ -1044,20 +1281,17 @@ "operationId": "DeleteFolder", "responses": { "204": { - "description": "", - "content": { - "application/json": { - "schema": {} - } - } + "description": "สำเร็จ" } }, "tags": [ - "Folder" + "แฟ้ม" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -1067,7 +1301,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ตู้เอกสาร 1" }, { "in": "path", @@ -1075,7 +1310,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ลิ้นชัก 1" }, { "in": "path", @@ -1083,7 +1319,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "แฟ้ม 1" } ] } @@ -1093,12 +1330,12 @@ "operationId": "SearchFile", "responses": { "200": { - "description": "", + "description": "สำเร็จ", "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/EhrFile" + "$ref": "#/components/schemas/StorageFile" }, "type": "array" } @@ -1107,9 +1344,13 @@ } }, "tags": [ - "Search" + "ค้นหา" + ], + "security": [ + { + "bearerAuth": [] + } ], - "security": [], "parameters": [], "requestBody": { "required": true, @@ -1128,59 +1369,42 @@ "operationId": "ListFolder", "responses": { "200": { - "description": "", + "description": "สำเร็จ", "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/EhrFolder" + "$ref": "#/components/schemas/StorageFolder" }, "type": "array" + }, + "examples": { + "Example 1": { + "value": [ + { + "path": "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 1/แฟ้มย่อย 1", + "name": "แฟ้มย่อย 1", + "createdAt": "2021-07-20T12:33:13.018Z", + "createdBy": "admin" + }, + { + "path": "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 1/แฟ้มย่อย 2", + "name": "แฟ้มย่อย 2", + "createdAt": "2022-01-23T16:05:02.114Z", + "createdBy": "admin" + } + ] + } } } } + }, + "500": { + "description": "เกิดข้อผิดพลาด ไม่สามารถแสดงรายการแฟ้มได้ กรุณาลองใหม่ในภายหลัง" } }, "tags": [ - "SubFolder" - ], - "security": [], - "parameters": [ - { - "in": "path", - "name": "cabinetName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "drawerName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "folderName", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "post": { - "operationId": "CreateFolder", - "responses": { - "201": { - "description": "" - } - }, - "tags": [ - "SubFolder" + "แฟ้มย่อย" ], "security": [ { @@ -1194,7 +1418,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ตู้เอกสาร 1" }, { "in": "path", @@ -1202,7 +1427,8 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "ลิ้นชัก 1" }, { "in": "path", @@ -1210,7 +1436,61 @@ "required": true, "schema": { "type": "string" - } + }, + "example": "แฟ้ม 1" + } + ] + }, + "post": { + "operationId": "CreateFolder", + "responses": { + "201": { + "description": "สำเร็จ" + }, + "404": { + "description": "ไม่พบของแฟ้ม" + }, + "500": { + "description": "เกิดข้อผิดพลาดกับระบบจัดการไฟล์" + } + }, + "tags": [ + "แฟ้มย่อย" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "cabinetName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ตู้เอกสาร 1" + }, + { + "in": "path", + "name": "drawerName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ลิ้นชัก 1" + }, + { + "in": "path", + "name": "folderName", + "required": true, + "schema": { + "type": "string" + }, + "example": "แฟ้ม 1" } ], "requestBody": { @@ -1238,17 +1518,281 @@ "operationId": "EditFolder", "responses": { "204": { - "description": "" + "description": "สำเร็จ" + }, + "500": { + "description": "เกิดข้อผิดพลาดไม่สามารถย้ายไฟล์ได้" } }, "tags": [ - "SubFolder" + "แฟ้มย่อย" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "cabinetName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ตู้เอกสาร 1" + }, + { + "in": "path", + "name": "drawerName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ลิ้นชัก 1" + }, + { + "in": "path", + "name": "folderName", + "required": true, + "schema": { + "type": "string" + }, + "example": "แฟ้ม 1" + }, + { + "in": "path", + "name": "subFolderName", + "required": true, + "schema": { + "type": "string" + }, + "example": "แฟ้มย่อย 1" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "example": "แฟ้มใหม่" + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + } + } + }, + "delete": { + "operationId": "DeleteFolder", + "responses": { + "204": { + "description": "สำเร็จ" + } + }, + "tags": [ + "แฟ้มย่อย" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "cabinetName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ตู้เอกสาร 1" + }, + { + "in": "path", + "name": "drawerName", + "required": true, + "schema": { + "type": "string" + }, + "example": "ลิ้นชัก 1" + }, + { + "in": "path", + "name": "folderName", + "required": true, + "schema": { + "type": "string" + }, + "example": "แฟ้ม 1" + }, + { + "in": "path", + "name": "subFolderName", + "required": true, + "schema": { + "type": "string" + }, + "example": "แฟ้มย่อย 1" + } + ] + } + }, + "/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/subfolder/{subFolderName}/file": { + "get": { + "operationId": "GetFile", + "responses": { + "200": { + "description": "สำเร็จ", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StorageFile" + }, + "type": "array" + } + } + } + } + }, + "tags": [ + "ไฟล์" ], "security": [ { "bearerAuth": [] } ], + "parameters": [ + { + "in": "path", + "name": "cabinetName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "drawerName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "folderName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "subFolderName", + "required": true, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "operationId": "UploadFile", + "responses": { + "201": { + "description": "สำเร็จ", + "content": { + "application/json": { + "schema": { + "properties": { + "keyword": { + "type": "string" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "file": { + "type": "string" + }, + "upload": { + "type": "string" + }, + "updatedBy": { + "type": "string" + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "createdBy": { + "type": "string" + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "format": "date-time" + } + ] + } + }, + "required": [ + "file", + "upload", + "updatedBy", + "updatedAt", + "createdBy", + "createdAt" + ], + "type": "object" + } + } + } + }, + "404": { + "description": "ตำแหน่งที่ระบุไม่พบ กรุณาเตรียมตำแหน่งที่ต้องการก่อนดำเนินการ" + } + }, + "tags": [ + "ไฟล์" + ], + "security": [ + { + "bearerAuth": [ + "admin" + ] + } + ], "parameters": [ { "in": "path", @@ -1289,291 +1833,30 @@ "application/json": { "schema": { "properties": { - "name": { + "keyword": { + "type": "string" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "file": { "type": "string" } }, "required": [ - "name" + "file" ], "type": "object" } } } } - }, - "delete": { - "operationId": "DeleteFolder", - "responses": { - "204": { - "description": "", - "content": { - "application/json": { - "schema": {} - } - } - } - }, - "tags": [ - "SubFolder" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ - { - "in": "path", - "name": "cabinetName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "drawerName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "folderName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "subFolderName", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/subfolder/{subFolderName}/file": { - "post": { - "operationId": "UploadFile", - "responses": { - "201": { - "description": "" - } - }, - "tags": [ - "SubFolder File" - ], - "security": [ - { - "bearerAuth": [] - } - ], - "parameters": [ - { - "in": "path", - "name": "cabinetName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "drawerName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "folderName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "subFolderName", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "file": { - "type": "string", - "format": "binary" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "keyword": { - "type": "string" - }, - "category": { - "type": "string" - } - }, - "required": [ - "file", - "title", - "description", - "keyword", - "category" - ] - } - } - } - } - }, - "get": { - "operationId": "GetFile", - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "items": { - "properties": { - "createdBy": { - "type": "string" - }, - "createdAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "updatedBy": { - "type": "string" - }, - "updatedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "string", - "format": "date-time" - } - ] - }, - "keyword": { - "items": { - "type": "string" - }, - "type": "array" - }, - "category": { - "items": { - "type": "string" - }, - "type": "array" - }, - "description": { - "type": "string" - }, - "title": { - "type": "string" - }, - "fileType": { - "type": "string" - }, - "fileSize": { - "type": "number", - "format": "double" - }, - "fileName": { - "type": "string" - }, - "pathname": { - "type": "string" - } - }, - "required": [ - "createdBy", - "createdAt", - "updatedBy", - "updatedAt", - "keyword", - "category", - "description", - "title", - "fileType", - "fileSize", - "fileName", - "pathname" - ], - "type": "object" - }, - "type": "array" - } - } - } - } - }, - "tags": [ - "SubFolder File" - ], - "security": [], - "parameters": [ - { - "in": "path", - "name": "cabinetName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "drawerName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "folderName", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "subFolderName", - "required": true, - "schema": { - "type": "string" - } - } - ] } }, "/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/subfolder/{subFolderName}/file/{fileName}": { @@ -1581,15 +1864,40 @@ "operationId": "UpdateFile", "responses": { "200": { - "description": "" + "description": "สำเร็จ", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {}, + { + "properties": { + "upload": { + "type": "string" + } + }, + "required": [ + "upload" + ], + "type": "object" + } + ] + } + } + } + }, + "404": { + "description": "ไม่พบตำแหน่งที่ต้องการสร้างแฟ้ม" } }, "tags": [ - "SubFolder File" + "ไฟล์" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -1635,29 +1943,28 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { - "multipart/form-data": { + "application/json": { "schema": { - "type": "object", "properties": { - "file": { - "type": "string", - "format": "binary" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, "keyword": { "type": "string" }, "category": { "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "file": { + "type": "string" } - } + }, + "type": "object" } } } @@ -1667,15 +1974,17 @@ "operationId": "DeleteFile", "responses": { "200": { - "description": "" + "description": "สำเร็จ" } }, "tags": [ - "SubFolder File" + "ไฟล์" ], "security": [ { - "bearerAuth": [] + "bearerAuth": [ + "admin" + ] } ], "parameters": [ @@ -1758,6 +2067,9 @@ } ] }, + "upload": { + "type": "boolean" + }, "keyword": { "items": { "type": "string" @@ -1798,6 +2110,7 @@ "createdAt", "updatedBy", "updatedAt", + "upload", "keyword", "category", "description", @@ -1815,9 +2128,13 @@ } }, "tags": [ - "File" + "ดาวน์โหลด" + ], + "security": [ + { + "bearerAuth": [] + } ], - "security": [], "parameters": [ { "in": "path", @@ -1868,5 +2185,28 @@ "url": "/api" } ], + "tags": [ + { + "name": "ตู้เอกสาร" + }, + { + "name": "ลิ้นชัก" + }, + { + "name": "แฟ้ม" + }, + { + "name": "แฟ้มย่อย" + }, + { + "name": "ไฟล์" + }, + { + "name": "ดาวน์โหลด" + }, + { + "name": "ค้นหา" + } + ], "basePath": "/api" } \ No newline at end of file diff --git a/Services/server/src/utils/auth.ts b/Services/server/src/utils/auth.ts index 407238a..5d71bec 100644 --- a/Services/server/src/utils/auth.ts +++ b/Services/server/src/utils/auth.ts @@ -14,26 +14,30 @@ const jwtVerify = createVerifier({ }, }); -export function expressAuthentication( +export async function expressAuthentication( request: express.Request, securityName: string, - _scopes?: string[], + scopes?: string[], ) { - return new Promise(async (resolve, reject) => { - if (securityName !== "bearerAuth") reject(new Error("Unknown authentication method.")); + if (process.env.AUTH_BYPASS) return { preferred_username: "bypassed" }; - const token = request.headers["authorization"]?.includes("Bearer ") - ? request.headers["authorization"].split(" ")[1] - : null; + if (securityName !== "bearerAuth") throw new Error("Unknown authentication method."); - if (!token) return reject(new HttpError(HttpStatusCode.UNAUTHORIZED, "No token provided.")); + const token = request.headers["authorization"]?.includes("Bearer ") + ? request.headers["authorization"].split(" ")[1] + : null; - const payload = await jwtVerify(token).catch((_) => null); + if (!token) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "No token provided."); - if (!payload) { - return reject(new HttpError(HttpStatusCode.UNAUTHORIZED, "Invalid token provided.")); - } + const payload = await jwtVerify(token).catch((_) => null); - return resolve(payload); - }); + if (!payload) { + throw new HttpError(HttpStatusCode.UNAUTHORIZED, "Invalid token provided."); + } + + if (scopes && !scopes.some((v) => payload.resource_access[payload.azp].roles.includes(v))) { + throw new HttpError(HttpStatusCode.FORBIDDEN, "You are not allowed to perform this action."); + } + + return payload; } diff --git a/Services/server/src/utils/minio.ts b/Services/server/src/utils/minio.ts index 3c3bc49..d01369d 100644 --- a/Services/server/src/utils/minio.ts +++ b/Services/server/src/utils/minio.ts @@ -1,37 +1,27 @@ -import { EhrFolder } from "../interfaces/ehr-fs"; +import { StorageFolder } from "../interfaces/storage-fs"; import * as Minio from "minio"; -import minioClient from "../storage"; +import minioClient from "../minio"; /** - * Remove slash at the start and ensure slash at the end of the path - * @param path - path to be check and ensure - * @returns path without / at start and end with trailing slash - */ -function safePath(path: string) { - return path.replace(/^\/|\/$/g, "") + "/"; -} - -/** - * Replace illegal character eg. ? % < > / \ : | that can't be in path with "-". + * Replace illegal character eg. ? % < > / \ : | that can't be in path with other char with dash by default. * @param path - string to check and replace - * @returns path with illegal character replaced with "-" + * @param replace - string to replace illegal character + * @returns illegal character replaced path */ -export function replaceIllegalChars(path: string, replaceChar = "-") { - return path.replace(/[/\\?%*:|"<>]/g, replaceChar); +export function replaceIllegalChars(path: string, replace = "-") { + return path.replace(/[/\\?%*:|"<>]/g, replace); } /** - * Utility function to check for .keep file if it is exist or not. - * @returns true if .keep exist, false otherwise + * Check if folder really exist by using ".keep" object. */ -export async function pathExist(path: string): Promise { - return await minioClient - .statObject("ehr", `${safePath(path)}.keep`) - .then((_) => true) - .catch((e) => { +export async function pathExist(bucket: string, path: string): Promise { + return Boolean( + await minioClient.statObject(bucket, `${path.replace(/^\/|\/$/g, "")}/.keep`).catch((e) => { if (e.code === "NotFound") return false; - throw new Error("Object Storage Error"); - }); + throw new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"); + }), + ); } /** @@ -39,55 +29,62 @@ export async function pathExist(path: string): Promise { * @param path - path to list * @return list of folder with metadata */ -export function listFolder(path?: string): Promise { - if (path) path = safePath(path); +export async function listFolder(bucket: string, path?: string): Promise { + if (path) path = `${path.replace(/^\/|\/$/g, "")}/`; - return new Promise((resolve, reject) => { - const folder: EhrFolder[] = []; - - const stream = minioClient.listObjectsV2("ehr", path ?? ""); + const list = await new Promise<{ pathname: string; name: string }[]>((resolve, reject) => { + const item: { pathname: string; name: string }[] = []; + const stream = minioClient.listObjectsV2(bucket, path ?? ""); stream.on("data", (v) => { - if (!(v && v.prefix)) return; - - folder.push({ - pathname: v.prefix, - name: v.prefix.slice(path?.length).split("/")[0], - createdAt: "N/A", - createdBy: "N/A", - }); - }); - - stream.on("end", async () => { - for (let i = 0; i < folder.length; i++) { - const stat = await minioClient - .statObject("ehr", `${folder[i].pathname}.keep`) - .catch((e) => console.error(`Error List Folder: ${folder[i].pathname}`, e)); - - if (!stat) continue; - - folder[i] = { - ...folder[i], - createdAt: stat.metaData.createdat ?? "N/A", - createdBy: stat.metaData.createdby ?? "N/A", - }; + if (v && v.prefix) { + item.push({ + pathname: v.prefix, + name: v.prefix.slice(path?.length).split("/")[0], + }); } - resolve(folder); }); - - stream.on("error", () => reject(new Error("Object storage error occured."))); + stream.on("end", () => resolve(item)); + stream.on("error", () => reject(new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"))); }); + + const folder = await Promise.all( + list.map(async (v) => { + // Get stat from hidden object that used to mark as folder as minio doesn't really have folder + const stat = await minioClient + .statObject(bucket, `${v.pathname}.keep`) + .catch((e) => console.error(`MinIO Error: ${e}`)); + + if (!stat) return undefined; + + const { createdat, createdby } = stat.metaData; + + return { + ...v, + createdAt: createdat ?? "n/a", + createdBy: createdby ?? "n/a", + } satisfies StorageFolder; + }), + ); + + return folder.filter((v: (typeof folder)[number]): v is StorageFolder => !!v); } -export async function listItem(path: string, recursive = false): Promise { +export async function listItem( + bucket: string, + path: string, + recursive = false, +): Promise { return new Promise((resolve, reject) => { - const stream = minioClient.listObjectsV2("ehr", path, recursive); + const stream = minioClient.listObjectsV2(bucket, path, recursive); const item: Minio.BucketItem[] = []; stream.on("data", (v) => { if (v && v.name) item.push(v); }); stream.on("end", () => resolve(item)); - stream.on("error", () => reject(new Error("Object storage error occured."))); + stream.on("error", () => reject(new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"))); }); } + +export const copyCond = new Minio.CopyConditions(); diff --git a/Services/server/tools/prepare.ts b/Services/server/tools/prepare.ts new file mode 100644 index 0000000..d1394ef --- /dev/null +++ b/Services/server/tools/prepare.ts @@ -0,0 +1,44 @@ +import "dotenv/config"; +import esClient from "../src/elasticsearch"; +import minioClient from "../src/minio"; + +const DEFAULT_BUCKET = process.env.MINIO_BUCKET; +const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +if (!DEFAULT_BUCKET) throw Error("Default MinIO bucket must be specified."); +if (!DEFAULT_INDEX) throw Error("Default ElasticSearch index must be specified."); + +esClient.ingest.putPipeline({ + id: "attachment", + body: { + description: "Extract attachment information", + processors: [ + { + attachment: { + field: "data", + }, + }, + { + remove: { + field: "data", + }, + }, + ], + }, +}); + +esClient.indices.putMapping({ + index: DEFAULT_INDEX, + body: { + properties: { + pathname: { + type: "keyword", + }, + }, + }, +}); + +minioClient.makeBucket(DEFAULT_BUCKET!, (e) => { + if (!e) console.log("Configuration needed for Bucket Notification to AMQP"); + console.error(e); +}); diff --git a/Services/server/tsoa.json b/Services/server/tsoa.json index e88a36b..24383ef 100644 --- a/Services/server/tsoa.json +++ b/Services/server/tsoa.json @@ -25,7 +25,16 @@ "description": "Keycloak Bearer Token", "in": "header" } - } + }, + "tags": [ + { "name": "ตู้เอกสาร" }, + { "name": "ลิ้นชัก" }, + { "name": "แฟ้ม" }, + { "name": "แฟ้มย่อย" }, + { "name": "ไฟล์" }, + { "name": "ดาวน์โหลด" }, + { "name": "ค้นหา" } + ] }, "routes": { "routesDir": "src",